@cocorograph/hub-agent 0.6.49 → 0.6.52
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/main.mjs +162 -2
- package/src/state.mjs +45 -1
- package/src/tmux.mjs +93 -0
- package/src/tui-permission-bridge.mjs +72 -9
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -31,7 +31,11 @@ import {
|
|
|
31
31
|
} from "./claude-history.mjs"
|
|
32
32
|
import { listAgents } from "./agents.mjs"
|
|
33
33
|
import { listSkills } from "./skills.mjs"
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
capturePane,
|
|
36
|
+
detectPermissionModeFromText,
|
|
37
|
+
listSessionStates,
|
|
38
|
+
} from "./state.mjs"
|
|
35
39
|
import {
|
|
36
40
|
DEFAULT_PROFILE_ID,
|
|
37
41
|
defaultConfigDir,
|
|
@@ -45,6 +49,7 @@ import {
|
|
|
45
49
|
buildClaudeCmd,
|
|
46
50
|
createSession as createTmuxSession,
|
|
47
51
|
createWorktreeDir,
|
|
52
|
+
cyclePermissionMode,
|
|
48
53
|
execTmux,
|
|
49
54
|
killManySessions,
|
|
50
55
|
killSession as killTmuxSession,
|
|
@@ -53,6 +58,7 @@ import {
|
|
|
53
58
|
listWorktreeStubs,
|
|
54
59
|
rebindClaudeSession,
|
|
55
60
|
removeWorktree as removeWorktreeDir,
|
|
61
|
+
resumeWithMessage,
|
|
56
62
|
} from "./tmux.mjs"
|
|
57
63
|
import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
|
|
58
64
|
import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
|
|
@@ -68,6 +74,28 @@ import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
|
|
|
68
74
|
|
|
69
75
|
const logger = pino({ name: "hub-agent" })
|
|
70
76
|
|
|
77
|
+
// 遅延回答 resume: 書き込んだ .decision がフックに消費されない (= フック死亡) かを
|
|
78
|
+
// 判定する猶予 (ms)。生存フックは ~0.15s で消費するので 2s 残れば死亡とみなす。
|
|
79
|
+
const RESUME_DECISION_GRACE_MS = 2000
|
|
80
|
+
// resume の二重実行防止 (request_id -> 実施時刻ms)。低頻度なので Map で十分。
|
|
81
|
+
const _tuiResumed = new Map()
|
|
82
|
+
function _markResumed(requestId) {
|
|
83
|
+
const now = Date.now()
|
|
84
|
+
// 古い項目を掃除 (10 分超)。
|
|
85
|
+
for (const [id, at] of _tuiResumed) {
|
|
86
|
+
if (now - at > 600000) _tuiResumed.delete(id)
|
|
87
|
+
}
|
|
88
|
+
if (_tuiResumed.has(requestId)) return false
|
|
89
|
+
_tuiResumed.set(requestId, now)
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
/** 遅延回答を新メッセージとして tmux セッションへ届けて会話を再開する。 */
|
|
93
|
+
async function _resumeTuiAnswer(sessionName, resumeText, requestId) {
|
|
94
|
+
if (!sessionName || !resumeText) return
|
|
95
|
+
if (!_markResumed(requestId)) return // 二重実行防止
|
|
96
|
+
await resumeWithMessage(sessionName, resumeText, { logger })
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
const BUNDLE_MANIFEST_PATH =
|
|
72
100
|
process.env.HUB_BUNDLE_MANIFEST ||
|
|
73
101
|
path.join(os.homedir(), ".claude", "scripts", "manifest.json")
|
|
@@ -711,6 +739,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
711
739
|
for (const s of states) {
|
|
712
740
|
let status = s.status
|
|
713
741
|
let contextPct = s.context_pct
|
|
742
|
+
// 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
|
|
743
|
+
// tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
|
|
744
|
+
const permissionMode = s.permission_mode ?? null
|
|
714
745
|
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
715
746
|
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
716
747
|
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
@@ -738,17 +769,20 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
738
769
|
if (
|
|
739
770
|
!prev ||
|
|
740
771
|
prev.status !== status ||
|
|
741
|
-
prev.context_pct !== contextPct
|
|
772
|
+
prev.context_pct !== contextPct ||
|
|
773
|
+
prev.permission_mode !== permissionMode
|
|
742
774
|
) {
|
|
743
775
|
lastByName.set(s.session_name, {
|
|
744
776
|
status,
|
|
745
777
|
context_pct: contextPct,
|
|
778
|
+
permission_mode: permissionMode,
|
|
746
779
|
})
|
|
747
780
|
client.send({
|
|
748
781
|
type: "session.state",
|
|
749
782
|
session_name: s.session_name,
|
|
750
783
|
status,
|
|
751
784
|
context_pct: contextPct,
|
|
785
|
+
permission_mode: permissionMode,
|
|
752
786
|
})
|
|
753
787
|
}
|
|
754
788
|
}
|
|
@@ -1041,6 +1075,32 @@ async function dispatch(msg, ctx) {
|
|
|
1041
1075
|
msg.updated_input,
|
|
1042
1076
|
)
|
|
1043
1077
|
: false
|
|
1078
|
+
// 遅延回答 resume (frontend が session_name + resume_text を載せた TUI 回答のみ)。
|
|
1079
|
+
// フックが既に死んでいて即時確定 (.decision 注入) が届かない場合、回答を新メッセージ
|
|
1080
|
+
// として同一セッションへ送り、永続 claude が文脈ごと続行できるようにする。
|
|
1081
|
+
const resumeText =
|
|
1082
|
+
typeof msg.resume_text === "string" ? msg.resume_text : ""
|
|
1083
|
+
const resumeSession =
|
|
1084
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
1085
|
+
if (resumeText && resumeSession && ctx.tuiPermissionBridge) {
|
|
1086
|
+
if (!handledByTui) {
|
|
1087
|
+
// 期限切れ (pending に無い) → 即 resume。再配信しないよう drop。
|
|
1088
|
+
ctx.tuiPermissionBridge.drop(msg.request_id)
|
|
1089
|
+
void _resumeTuiAnswer(resumeSession, resumeText, msg.request_id)
|
|
1090
|
+
} else {
|
|
1091
|
+
// pending だった: 生存フックは ~0.15s で .decision を消費する。猶予後も
|
|
1092
|
+
// 残っていれば フック死亡 (Claude Code timeout で kill 等) → resume へ。
|
|
1093
|
+
const reqId = msg.request_id
|
|
1094
|
+
setTimeout(() => {
|
|
1095
|
+
if (ctx.tuiPermissionBridge?.hasUnconsumedDecision(reqId)) {
|
|
1096
|
+
ctx.tuiPermissionBridge.dropDecision(reqId).catch(() => {})
|
|
1097
|
+
ctx.tuiPermissionBridge.drop(reqId)
|
|
1098
|
+
void _resumeTuiAnswer(resumeSession, resumeText, reqId)
|
|
1099
|
+
}
|
|
1100
|
+
}, RESUME_DECISION_GRACE_MS)
|
|
1101
|
+
}
|
|
1102
|
+
return
|
|
1103
|
+
}
|
|
1044
1104
|
if (handledByTui) return
|
|
1045
1105
|
if (!ctx.claudeBridge) return
|
|
1046
1106
|
ctx.claudeBridge.permissionReply({
|
|
@@ -1127,6 +1187,106 @@ async function dispatch(msg, ctx) {
|
|
|
1127
1187
|
// jsonl tail も即停止 (閲覧していない session を追従し続けない)。
|
|
1128
1188
|
ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
|
|
1129
1189
|
return
|
|
1190
|
+
case "claude.tui.cyclePermission": {
|
|
1191
|
+
// 権限バッジ押下 → 対話 claude TUI へ shift+tab を送って権限モードを循環。
|
|
1192
|
+
// 旧実装は frontend が raw pty.data (BACKTAB) を送り、各端末が楽観値を
|
|
1193
|
+
// 個別に 1 段進めるだけだったため、複数端末間でズレ、かつ tmux 上で直接
|
|
1194
|
+
// 切り替えた変更とも乖離した。ここで agent が実キー送出 → ペイン再読込で
|
|
1195
|
+
// 実モードを確定 → 全ブラウザへ claude.tui.permission を broadcast し、
|
|
1196
|
+
// 「実際に動いているターミナルの状態」を正本として全端末に同期する。
|
|
1197
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
1198
|
+
const sessionName =
|
|
1199
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
1200
|
+
if (!sessionName) return
|
|
1201
|
+
;(async () => {
|
|
1202
|
+
try {
|
|
1203
|
+
await cyclePermissionMode(sessionName, { logger })
|
|
1204
|
+
// TUI がフッターバナーを再描画するまで少し待ってから実モードを読む。
|
|
1205
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
1206
|
+
const text = await capturePane(sessionName, { noCache: true })
|
|
1207
|
+
const mode = detectPermissionModeFromText(text)
|
|
1208
|
+
ctx.client.send({
|
|
1209
|
+
type: "claude.tui.permission",
|
|
1210
|
+
cwd: cwd || undefined,
|
|
1211
|
+
session_name: sessionName,
|
|
1212
|
+
permission_mode: mode,
|
|
1213
|
+
})
|
|
1214
|
+
logger.info(
|
|
1215
|
+
{ session: sessionName, cwd, mode },
|
|
1216
|
+
"tui permission cycled → notified browser",
|
|
1217
|
+
)
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
logger.warn(
|
|
1220
|
+
{ err: err?.message, session: sessionName },
|
|
1221
|
+
"claude.tui.cyclePermission failed",
|
|
1222
|
+
)
|
|
1223
|
+
}
|
|
1224
|
+
})()
|
|
1225
|
+
return
|
|
1226
|
+
}
|
|
1227
|
+
case "claude.tui.probePermission": {
|
|
1228
|
+
// 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
|
|
1229
|
+
// 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
|
|
1230
|
+
// ブラウザがマウント直後に「今ターミナルが何モードか」を取得し、楽観値や
|
|
1231
|
+
// 古い jsonl 値とのズレを開始時点から揃えるために使う。検出不能 (claude 起動中・
|
|
1232
|
+
// 素のシェル) のときは mode=null となり、frontend 側は据え置く。
|
|
1233
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
1234
|
+
const sessionName =
|
|
1235
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
1236
|
+
if (!sessionName) return
|
|
1237
|
+
;(async () => {
|
|
1238
|
+
try {
|
|
1239
|
+
const text = await capturePane(sessionName, { noCache: true })
|
|
1240
|
+
const mode = detectPermissionModeFromText(text)
|
|
1241
|
+
// 検出できた時だけ通知する (null で badge を消さない)。
|
|
1242
|
+
if (!mode) return
|
|
1243
|
+
ctx.client.send({
|
|
1244
|
+
type: "claude.tui.permission",
|
|
1245
|
+
cwd: cwd || undefined,
|
|
1246
|
+
session_name: sessionName,
|
|
1247
|
+
permission_mode: mode,
|
|
1248
|
+
})
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
logger.warn(
|
|
1251
|
+
{ err: err?.message, session: sessionName },
|
|
1252
|
+
"claude.tui.probePermission failed",
|
|
1253
|
+
)
|
|
1254
|
+
}
|
|
1255
|
+
})()
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
case "claude.tui.rehydratePermissions": {
|
|
1259
|
+
// セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
|
|
1260
|
+
// state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
|
|
1261
|
+
// browser が再購読時にこれを送り、該当セッションの未応答リクエストを再 push して
|
|
1262
|
+
// カードを復元する (frontend は request_id で重複排除)。
|
|
1263
|
+
if (!ctx.tuiPermissionBridge) return
|
|
1264
|
+
const sid =
|
|
1265
|
+
typeof msg.session_id === "string" ? msg.session_id : null
|
|
1266
|
+
const reqCwd = typeof msg.cwd === "string" ? msg.cwd : null
|
|
1267
|
+
const pend = ctx.tuiPermissionBridge.listPending({
|
|
1268
|
+
session_id: sid,
|
|
1269
|
+
cwd: reqCwd,
|
|
1270
|
+
})
|
|
1271
|
+
for (const p of pend) {
|
|
1272
|
+
ctx.client.send({
|
|
1273
|
+
type: "claude.permission.request",
|
|
1274
|
+
stream_id: null,
|
|
1275
|
+
session_id: p.session_id,
|
|
1276
|
+
cwd: p.cwd,
|
|
1277
|
+
request_id: p.request_id,
|
|
1278
|
+
tool_name: p.tool_name,
|
|
1279
|
+
input: p.input,
|
|
1280
|
+
})
|
|
1281
|
+
}
|
|
1282
|
+
if (pend.length) {
|
|
1283
|
+
logger.info(
|
|
1284
|
+
{ session_id: sid, cwd: reqCwd, count: pend.length },
|
|
1285
|
+
"tui permission re-hydrated to browser",
|
|
1286
|
+
)
|
|
1287
|
+
}
|
|
1288
|
+
return
|
|
1289
|
+
}
|
|
1130
1290
|
case "claude.tui.bind": {
|
|
1131
1291
|
// T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
|
|
1132
1292
|
// 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
|
package/src/state.mjs
CHANGED
|
@@ -82,6 +82,43 @@ export function detectContextPctFromText(text) {
|
|
|
82
82
|
return null
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// 対話 claude TUI のフッターに出る権限モードバナー (claude v2.1 系で実機確認)。
|
|
86
|
+
// shift+tab で循環: default(バナー無し) → accept edits → plan → auto → …
|
|
87
|
+
const PERMISSION_BANNERS = [
|
|
88
|
+
[/⏵⏵\s*accept edits on/i, "acceptEdits"],
|
|
89
|
+
[/⏸\s*plan mode on/i, "plan"],
|
|
90
|
+
[/⏵⏵\s*auto mode on/i, "auto"],
|
|
91
|
+
[/bypass permissions on/i, "bypassPermissions"],
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 対話 claude TUI のペインテキストから現在の権限モードを判定する。
|
|
96
|
+
*
|
|
97
|
+
* 全端末で「実際に動いているターミナルの権限状態」を同期するための正本。
|
|
98
|
+
* jsonl の permissionMode は送信ターン毎にしか記録されないため、shift+tab した
|
|
99
|
+
* だけ (未送信) の変更や tmux 上で直接切り替えた変更は jsonl に出ない。ペインの
|
|
100
|
+
* フッターバナーは shift+tab 直後に即更新されるので、これを正本にする。
|
|
101
|
+
*
|
|
102
|
+
* - 明示バナー (accept edits / plan / auto / bypass) があればそのモード。
|
|
103
|
+
* - バナーが無い場合、claude TUI のフッターが見えている時だけ "default" と判定する
|
|
104
|
+
* (素のシェルを default と誤検出しないためのガード)。
|
|
105
|
+
* - claude TUI と判別できなければ null (= 不明、呼び出し側は据え置き)。
|
|
106
|
+
*
|
|
107
|
+
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
108
|
+
* @returns {string|null}
|
|
109
|
+
*/
|
|
110
|
+
export function detectPermissionModeFromText(text) {
|
|
111
|
+
if (!text) return null
|
|
112
|
+
for (const [re, mode] of PERMISSION_BANNERS) {
|
|
113
|
+
if (re.test(text)) return mode
|
|
114
|
+
}
|
|
115
|
+
// バナー無し = default。ただし claude TUI のフッターが見えている時だけ assert する。
|
|
116
|
+
if (/← for agents|for shortcuts|shift\+tab to cycle/i.test(text)) {
|
|
117
|
+
return "default"
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
85
122
|
export async function listSessionNames(opts = {}) {
|
|
86
123
|
const tmuxBin = opts.tmuxBin || "tmux"
|
|
87
124
|
try {
|
|
@@ -165,6 +202,7 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
165
202
|
const text = await capturePane(sessionName, opts)
|
|
166
203
|
const defaultStatus = detectStatusFromText(text)
|
|
167
204
|
const defaultContextPct = detectContextPctFromText(text)
|
|
205
|
+
const defaultPermissionMode = detectPermissionModeFromText(text)
|
|
168
206
|
|
|
169
207
|
if (opts.plugins && opts.plugins.length) {
|
|
170
208
|
const hookResult = await runHookChain(opts.plugins, "transformStatusDetection", {
|
|
@@ -177,11 +215,17 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
177
215
|
return {
|
|
178
216
|
status: hookResult.result.status || defaultStatus,
|
|
179
217
|
context_pct: hookResult.result.context_pct ?? defaultContextPct,
|
|
218
|
+
permission_mode:
|
|
219
|
+
hookResult.result.permission_mode ?? defaultPermissionMode,
|
|
180
220
|
}
|
|
181
221
|
}
|
|
182
222
|
}
|
|
183
223
|
|
|
184
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
status: defaultStatus,
|
|
226
|
+
context_pct: defaultContextPct,
|
|
227
|
+
permission_mode: defaultPermissionMode,
|
|
228
|
+
}
|
|
185
229
|
}
|
|
186
230
|
|
|
187
231
|
/** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
|
package/src/tmux.mjs
CHANGED
|
@@ -716,6 +716,99 @@ export function buildResumeCmd(sessionId, opts = {}) {
|
|
|
716
716
|
|
|
717
717
|
const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
718
718
|
|
|
719
|
+
/**
|
|
720
|
+
* 遅延回答 resume: 承認/質問カードへの回答がフックの生存中に間に合わなかったとき、
|
|
721
|
+
* 回答を「新しいユーザーメッセージ」として同一 tmux セッションの対話 claude へ届ける。
|
|
722
|
+
*
|
|
723
|
+
* TUI バックエンドの claude は tmux 上で永続し会話文脈を保持するため、回答を新メッセージ
|
|
724
|
+
* として送れば「離席→タイムアウト」後でも文脈を引き継いで続行できる (ユーザー指示の設計)。
|
|
725
|
+
*
|
|
726
|
+
* フックがタイムアウトすると claude は native の質問/承認 UI を pane に出してターンを
|
|
727
|
+
* 止めている。先に Escape を送ってそれを畳み (素の入力欄では無害)、少し待ってから回答
|
|
728
|
+
* テキストを送って Enter で確定する。テキストは send-keys -l で「文字列リテラル」として
|
|
729
|
+
* 送り、改行は空白へ畳んで単一行にする (誤確定防止)。ベストエフォート。
|
|
730
|
+
*
|
|
731
|
+
* @param {string} name tmux セッション名
|
|
732
|
+
* @param {string} text 送る回答メッセージ (人間可読)
|
|
733
|
+
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
734
|
+
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
735
|
+
*/
|
|
736
|
+
export async function resumeWithMessage(name, text, opts = {}) {
|
|
737
|
+
const bin = tmuxBin(opts)
|
|
738
|
+
const line = String(text || "")
|
|
739
|
+
.replace(/[\r\n]+/g, " ")
|
|
740
|
+
.trim()
|
|
741
|
+
if (!line) return { ok: false, error: "empty resume text" }
|
|
742
|
+
try {
|
|
743
|
+
// 1) native の質問/承認 UI を畳む (素の入力欄なら入力クリアで無害)。
|
|
744
|
+
await execFileP(bin, ["send-keys", "-t", name, "Escape"])
|
|
745
|
+
await _delay(200)
|
|
746
|
+
// 2) 回答テキストをリテラルで送る (-l でキー名解釈を避ける)。
|
|
747
|
+
await execFileP(bin, ["send-keys", "-t", name, "-l", line])
|
|
748
|
+
await _delay(120)
|
|
749
|
+
// 3) Enter で確定 (send-keys の離散イベントなので paste 吸収は起きにくい)。
|
|
750
|
+
await execFileP(bin, ["send-keys", "-t", name, "Enter"])
|
|
751
|
+
opts.logger?.info(
|
|
752
|
+
{ session: name, len: line.length },
|
|
753
|
+
"tui resume: delivered late answer as new message",
|
|
754
|
+
)
|
|
755
|
+
return { ok: true }
|
|
756
|
+
} catch (err) {
|
|
757
|
+
opts.logger?.warn(
|
|
758
|
+
{ session: name, err: err?.message },
|
|
759
|
+
"resumeWithMessage failed",
|
|
760
|
+
)
|
|
761
|
+
return { ok: false, error: err?.message || String(err) }
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* 対話 claude TUI に shift+tab (BACKTAB) を 1 回送って権限モードを循環させる。
|
|
767
|
+
*
|
|
768
|
+
* 全端末で「実際に動いているターミナルの権限状態」を同期する仕組みの書込側
|
|
769
|
+
* (読込側は state.mjs の detectPermissionModeFromText)。フロントからの権限バッジ
|
|
770
|
+
* 押下は raw pty.data ではなく `claude.tui.cyclePermission` を送り、agent 側で
|
|
771
|
+
* 本関数を実行 → ペイン再読込で実モードを確定 → 全ブラウザへ broadcast する。
|
|
772
|
+
* これにより楽観値が端末間でズレず、jsonl 未記録 (未送信) の変更も即同期される。
|
|
773
|
+
*
|
|
774
|
+
* copy-mode 等に入っているとキーが奪われるので、入っていれば先に抜ける
|
|
775
|
+
* (フロントの cancelTmuxMode 相当を agent 側でも担保)。ベストエフォート。
|
|
776
|
+
*
|
|
777
|
+
* @param {string} name tmux セッション名
|
|
778
|
+
* @param {{logger?:object,tmuxBin?:string}} [opts]
|
|
779
|
+
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
780
|
+
*/
|
|
781
|
+
export async function cyclePermissionMode(name, opts = {}) {
|
|
782
|
+
const bin = tmuxBin(opts)
|
|
783
|
+
try {
|
|
784
|
+
// copy-mode 等に入っていると BTab が奪われるので、入っている時だけ抜ける。
|
|
785
|
+
try {
|
|
786
|
+
const { stdout } = await execFileP(bin, [
|
|
787
|
+
"display-message",
|
|
788
|
+
"-p",
|
|
789
|
+
"-t",
|
|
790
|
+
`${name}:`,
|
|
791
|
+
"-F",
|
|
792
|
+
"#{pane_in_mode}",
|
|
793
|
+
])
|
|
794
|
+
if (stdout.trim() === "1") {
|
|
795
|
+
await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
|
|
796
|
+
}
|
|
797
|
+
} catch {
|
|
798
|
+
// pane_in_mode 取得失敗はベストエフォートで無視 (そのまま BTab を送る)。
|
|
799
|
+
}
|
|
800
|
+
// BTab = shift+tab。claude TUI が権限モードを 1 段循環する。
|
|
801
|
+
await execFileP(bin, ["send-keys", "-t", name, "BTab"])
|
|
802
|
+
return { ok: true }
|
|
803
|
+
} catch (err) {
|
|
804
|
+
opts.logger?.warn(
|
|
805
|
+
{ session: name, err: err?.message },
|
|
806
|
+
"cyclePermissionMode failed",
|
|
807
|
+
)
|
|
808
|
+
return { ok: false, error: err?.message || String(err) }
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
719
812
|
/**
|
|
720
813
|
* tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
|
|
721
814
|
* (T04784 TUI resume binding)。
|
|
@@ -15,15 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { EventEmitter } from "node:events"
|
|
18
|
-
import { watch } from "node:fs"
|
|
18
|
+
import { watch, existsSync } from "node:fs"
|
|
19
19
|
import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
20
20
|
import path from "node:path"
|
|
21
21
|
|
|
22
22
|
export const PERMISSION_REQUESTS_DIR =
|
|
23
23
|
process.env.COCKPIT_PERMISSION_DIR || "/tmp/cockpit_permission_requests"
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* pending request の TTL。これを超えた seen/pending エントリは GC する。
|
|
27
|
+
*
|
|
28
|
+
* 旧 5 分から延長 (6h)。理由 2 点:
|
|
29
|
+
* - 遅延回答 resume: フックが死んだ後でもユーザーが戻って回答→新メッセージで再開できる
|
|
30
|
+
* よう、回答可能な状態を長く保つ (resolve は 2s の .decision 未消費判定で resume へ倒す
|
|
31
|
+
* ので、_pending を長く保っても二重確定は起きない)。
|
|
32
|
+
* - 承認カードの re-hydrate: セッション切替でビューが再マウントしてもカードを復元できるよう、
|
|
33
|
+
* 未応答リクエストのペイロードを保持しておく (listPending)。
|
|
34
|
+
*/
|
|
35
|
+
const PENDING_TTL_MS = Number(process.env.COCKPIT_PENDING_TTL_MS) || 6 * 60 * 60 * 1000
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* TUI フック ⇄ browser の権限往復をファイル IPC で仲介する。
|
|
@@ -41,7 +50,7 @@ export class TuiPermissionBridge extends EventEmitter {
|
|
|
41
50
|
super()
|
|
42
51
|
this.dir = dir
|
|
43
52
|
this.logger = logger
|
|
44
|
-
/** @type {Map<string, number>} request_id -> 受理時刻(ms) */
|
|
53
|
+
/** @type {Map<string, {payload: object, at: number}>} request_id -> ペイロード + 受理時刻(ms) */
|
|
45
54
|
this._pending = new Map()
|
|
46
55
|
/** @type {Set<string>} 二重発火防止 (request_id) */
|
|
47
56
|
this._seen = new Set()
|
|
@@ -96,14 +105,43 @@ export class TuiPermissionBridge extends EventEmitter {
|
|
|
96
105
|
}
|
|
97
106
|
this._gc()
|
|
98
107
|
this._seen.add(request_id)
|
|
99
|
-
|
|
100
|
-
this.emit("permission", {
|
|
108
|
+
const payload = {
|
|
101
109
|
request_id,
|
|
102
110
|
session_id: body.session_id ?? null,
|
|
103
111
|
cwd: body.cwd ?? null,
|
|
104
112
|
tool_name: body.tool_name ?? "",
|
|
105
113
|
input: body.tool_input ?? {},
|
|
106
|
-
}
|
|
114
|
+
}
|
|
115
|
+
// payload も保持する (セッション切替でビュー再マウント時の re-hydrate / listPending 用)。
|
|
116
|
+
this._pending.set(request_id, { payload, at: Date.now() })
|
|
117
|
+
this.emit("permission", payload)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 指定セッション宛の未応答リクエストのペイロード一覧を返す (re-hydrate 用)。
|
|
122
|
+
*
|
|
123
|
+
* セッション切替でビューが再マウントすると、その時点で持っていた承認/質問カードは
|
|
124
|
+
* React state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
|
|
125
|
+
* 戻ってきたときに復元できるよう、browser が再購読時にこれを引いて未応答カードを
|
|
126
|
+
* 再配信する。session_id 一致を主、cwd 一致を従にして絞る。
|
|
127
|
+
*
|
|
128
|
+
* @param {{session_id?: string|null, cwd?: string|null}} q
|
|
129
|
+
* @returns {object[]} emit と同形のペイロード配列
|
|
130
|
+
*/
|
|
131
|
+
listPending({ session_id, cwd } = {}) {
|
|
132
|
+
const out = []
|
|
133
|
+
for (const { payload } of this._pending.values()) {
|
|
134
|
+
const sidOk = session_id && payload.session_id === session_id
|
|
135
|
+
const cwdOk = cwd && payload.cwd === cwd
|
|
136
|
+
if (sidOk || cwdOk) out.push(payload)
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** request を pending から完全に落とす (resume 等で「もう再配信しない」とき)。 */
|
|
142
|
+
drop(request_id) {
|
|
143
|
+
this._pending.delete(request_id)
|
|
144
|
+
this._seen.delete(request_id)
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
/**
|
|
@@ -150,11 +188,36 @@ export class TuiPermissionBridge extends EventEmitter {
|
|
|
150
188
|
return true
|
|
151
189
|
}
|
|
152
190
|
|
|
191
|
+
/**
|
|
192
|
+
* 書き込んだ ``.decision`` がまだ消費されていないか判定する (遅延回答 resume 用)。
|
|
193
|
+
*
|
|
194
|
+
* フックが生存していれば ~0.15s のポーリングで ``.decision`` を読んで即 unlink する
|
|
195
|
+
* (_poll_decision の _cleanup)。書き込み後しばらく経っても ``.decision`` が残っている
|
|
196
|
+
* = フックは既に死んでいる (Claude Code のフック timeout で kill / TTL 経過で abstain
|
|
197
|
+
* 済み) と判定でき、呼び出し側は「回答を新メッセージとしてセッションへ届ける resume」
|
|
198
|
+
* へフォールバックする。
|
|
199
|
+
*
|
|
200
|
+
* @param {string} request_id
|
|
201
|
+
* @returns {boolean} ``.decision`` が残っていれば true (= 未消費 = フック死亡)
|
|
202
|
+
*/
|
|
203
|
+
hasUnconsumedDecision(request_id) {
|
|
204
|
+
return existsSync(path.join(this.dir, `${request_id}.decision`))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 未消費の ``.decision`` を掃除する (resume へフォールバックしたとき呼ぶ)。 */
|
|
208
|
+
async dropDecision(request_id) {
|
|
209
|
+
try {
|
|
210
|
+
await unlink(path.join(this.dir, `${request_id}.decision`))
|
|
211
|
+
} catch {
|
|
212
|
+
/* 既に無ければ no-op */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
153
216
|
/** TTL 超過の seen/pending を掃除する。 */
|
|
154
217
|
_gc() {
|
|
155
218
|
const now = Date.now()
|
|
156
|
-
for (const [id,
|
|
157
|
-
if (now - at > PENDING_TTL_MS) {
|
|
219
|
+
for (const [id, entry] of this._pending) {
|
|
220
|
+
if (now - entry.at > PENDING_TTL_MS) {
|
|
158
221
|
this._pending.delete(id)
|
|
159
222
|
this._seen.delete(id)
|
|
160
223
|
}
|