@cocorograph/hub-agent 0.6.44 → 0.6.46
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-stream-bridge.mjs +45 -0
- package/src/jsonl-live-watchers.mjs +185 -0
- package/src/main.mjs +84 -0
- package/src/tmux.mjs +112 -0
package/package.json
CHANGED
|
@@ -1136,6 +1136,51 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1136
1136
|
}
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* 指定 cwd / session_id に紐づく SDK セッションを**即停止**する (T04784 TUI bind 用)。
|
|
1141
|
+
*
|
|
1142
|
+
* TUI モードに切り替えた session は対話 TUI (tmux/PTY) の claude が write を担うため、
|
|
1143
|
+
* SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
|
|
1144
|
+
* (DESIGN.md C7)。soft-detach (7 日 resident) では止まらないので、ここで明示 close する。
|
|
1145
|
+
*
|
|
1146
|
+
* @param {{cwd?: string, session_id?: string}} sel
|
|
1147
|
+
* @returns {number} 停止したセッション数
|
|
1148
|
+
*/
|
|
1149
|
+
stopSessionsFor({ cwd, session_id } = {}) {
|
|
1150
|
+
const victims = new Set()
|
|
1151
|
+
if (session_id) {
|
|
1152
|
+
const s = this._liveBySession.get(session_id)
|
|
1153
|
+
if (s) victims.add(s)
|
|
1154
|
+
}
|
|
1155
|
+
if (cwd) {
|
|
1156
|
+
for (const s of this._liveBySession.values()) {
|
|
1157
|
+
if (s.cwd === cwd) victims.add(s)
|
|
1158
|
+
}
|
|
1159
|
+
// session_id 未確定 (起動直後) の端末は sessions Map 側で cwd 一致を拾う。
|
|
1160
|
+
for (const s of this.sessions.values()) {
|
|
1161
|
+
if (s.cwd === cwd) victims.add(s)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
let stopped = 0
|
|
1165
|
+
for (const s of victims) {
|
|
1166
|
+
if (s._closed) continue
|
|
1167
|
+
this._dropSessionMappings(s)
|
|
1168
|
+
try {
|
|
1169
|
+
s.close()
|
|
1170
|
+
} catch {
|
|
1171
|
+
/* ignore */
|
|
1172
|
+
}
|
|
1173
|
+
this.emit("exit", {
|
|
1174
|
+
stream_id: s.stream_id,
|
|
1175
|
+
code: 0,
|
|
1176
|
+
reason: "tui-bind-stop",
|
|
1177
|
+
session_id: s.sessionId,
|
|
1178
|
+
})
|
|
1179
|
+
stopped++
|
|
1180
|
+
}
|
|
1181
|
+
return stopped
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1139
1184
|
/**
|
|
1140
1185
|
* 新しい Claude セッションを開始する。
|
|
1141
1186
|
*
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonl ライブ追従 watcher の束ね役 (T04784 逐次表示)。
|
|
3
|
+
*
|
|
4
|
+
* Cockpit の TUI チャットは「対話 TUI (tmux/PTY) の claude が書く jsonl」を表示源にする。
|
|
5
|
+
* 従来は session.event (ターン境界) を合図に jsonl 全体を再 hydrate して「どさっと」更新
|
|
6
|
+
* していたが、ターン中の途中経過は 2.5s ポーリングで拾うしかなく、逐次性が弱かった。
|
|
7
|
+
*
|
|
8
|
+
* 本 manager は、ブラウザが送る `claude.tui.viewing {session_id, cwd}` ハートビート
|
|
9
|
+
* (= まさにその session を TUI チャットで閲覧している瞬間) を起点に、対象 session の
|
|
10
|
+
* jsonl を byte offset で tail し (claude-history-watch.mjs)、追記行を 1 行ずつ
|
|
11
|
+
* `claude.jsonl.event` として WS push する。これでターン中の本文・ツール結果が逐次
|
|
12
|
+
* ブラウザに届き、再 hydrate / ポーリングが不要になる。
|
|
13
|
+
*
|
|
14
|
+
* ライフサイクルは watcher ゲートの TuiViewerRegistry と相似形:
|
|
15
|
+
* - note(): 閲覧ハートビートで watcher を起動 or TTL 延長。
|
|
16
|
+
* - unwatch(): claude.tui.unviewing で即停止。
|
|
17
|
+
* - _sweep(): TTL 失効した watcher を掃除 (unviewing 取りこぼし対策)。
|
|
18
|
+
*
|
|
19
|
+
* 課金枠への影響なし: jsonl の読み取りはローカル FS の追従であり、モデル呼び出しを
|
|
20
|
+
* 伴わない (SDK query を起こさない)。TUI モード = サブスク枠のまま。
|
|
21
|
+
*
|
|
22
|
+
* fromEnd=true で監視開始時点の既存内容はスキップする。初回履歴は claude.history.request
|
|
23
|
+
* で別途 hydrate 済みのため、tail は新規分だけでよい (二重 push は frontend が uuid で排除)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { watchSessionFile } from "./claude-history-watch.mjs"
|
|
27
|
+
import { jsonlPath } from "./claude-history.mjs"
|
|
28
|
+
|
|
29
|
+
/** watcher 鮮度 (ms)。ブラウザのハートビート間隔 (5s) の 3 倍を既定とする。 */
|
|
30
|
+
const WATCHER_TTL_MS = Number(process.env.COCKPIT_JSONL_WATCH_TTL_MS) || 15_000
|
|
31
|
+
/** TTL 失効 watcher を掃除する周期 (ms)。 */
|
|
32
|
+
const SWEEP_INTERVAL_MS = 30 * 1000
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* session_id ごとに 1 つの jsonl tail watcher を保持し、閲覧ハートビートで延命する。
|
|
36
|
+
*/
|
|
37
|
+
export class JsonlLiveWatchers {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} args
|
|
40
|
+
* @param {(obj: object) => unknown} args.send - WS 送信 (client.send バインド済み)
|
|
41
|
+
* @param {() => Promise<string|undefined>|string|undefined} args.getProjectsRoot
|
|
42
|
+
* - ~/.claude/projects の実効ルート解決 (アカウント切替を反映)
|
|
43
|
+
* @param {number} [args.ttlMs]
|
|
44
|
+
* @param {import('pino').Logger} [args.logger]
|
|
45
|
+
*/
|
|
46
|
+
constructor({ send, getProjectsRoot, ttlMs = WATCHER_TTL_MS, logger } = {}) {
|
|
47
|
+
this.send = send
|
|
48
|
+
this.getProjectsRoot = getProjectsRoot
|
|
49
|
+
this.ttlMs = ttlMs
|
|
50
|
+
this.logger = logger
|
|
51
|
+
/** @type {Map<string, {watcher: {stop: () => void}, cwd: string, expiresAt: number}>} */
|
|
52
|
+
this._entries = new Map()
|
|
53
|
+
this._sweepTimer = null
|
|
54
|
+
/** 同一 session に対する note() の競合を直列化する (二重 watcher 防止)。 */
|
|
55
|
+
this._starting = new Set()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 周期 sweep を張る。 */
|
|
59
|
+
start() {
|
|
60
|
+
this._sweepTimer = setInterval(() => this._sweep(), SWEEP_INTERVAL_MS)
|
|
61
|
+
// 他 timer 同様 unref して agent のアイドル終了を妨げない。
|
|
62
|
+
this._sweepTimer?.unref?.()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 閲覧ハートビートを受けて watcher を起動 or 延命する。
|
|
67
|
+
*
|
|
68
|
+
* @param {{session_id?: string|null, cwd?: string|null}} info
|
|
69
|
+
* @returns {Promise<void>}
|
|
70
|
+
*/
|
|
71
|
+
async note({ session_id, cwd } = {}) {
|
|
72
|
+
if (!session_id || !cwd) return
|
|
73
|
+
const existing = this._entries.get(session_id)
|
|
74
|
+
if (existing) {
|
|
75
|
+
// 既に追従中: TTL を延長するだけ (cwd が変わるのは想定外なので無視)。
|
|
76
|
+
existing.expiresAt = Date.now() + this.ttlMs
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
// 起動中の重複呼び出しを弾く (ハートビートが連続して来ても watcher は 1 本)。
|
|
80
|
+
if (this._starting.has(session_id)) return
|
|
81
|
+
this._starting.add(session_id)
|
|
82
|
+
try {
|
|
83
|
+
const projectsRoot = await this._resolveProjectsRoot()
|
|
84
|
+
// 起動処理中に別経路で登録済みになっていないか再チェック。
|
|
85
|
+
if (this._entries.has(session_id)) return
|
|
86
|
+
const filePath = jsonlPath({ cwd, session_id, projectsRoot })
|
|
87
|
+
const watcher = watchSessionFile({
|
|
88
|
+
filePath,
|
|
89
|
+
fromEnd: true,
|
|
90
|
+
logger: this.logger,
|
|
91
|
+
onEvent: (event) => {
|
|
92
|
+
try {
|
|
93
|
+
this.send?.({
|
|
94
|
+
type: "claude.jsonl.event",
|
|
95
|
+
session_id,
|
|
96
|
+
cwd,
|
|
97
|
+
event,
|
|
98
|
+
})
|
|
99
|
+
} catch (err) {
|
|
100
|
+
this.logger?.warn(
|
|
101
|
+
{ err: err?.message },
|
|
102
|
+
"jsonl live watch send failed",
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
this._entries.set(session_id, {
|
|
108
|
+
watcher,
|
|
109
|
+
cwd,
|
|
110
|
+
expiresAt: Date.now() + this.ttlMs,
|
|
111
|
+
})
|
|
112
|
+
} catch (err) {
|
|
113
|
+
this.logger?.warn(
|
|
114
|
+
{ err: err?.message, session_id },
|
|
115
|
+
"jsonl live watch start failed",
|
|
116
|
+
)
|
|
117
|
+
} finally {
|
|
118
|
+
this._starting.delete(session_id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 閲覧終了を受けて watcher を即停止する。
|
|
124
|
+
*
|
|
125
|
+
* @param {{session_id?: string|null}} info
|
|
126
|
+
*/
|
|
127
|
+
unwatch({ session_id } = {}) {
|
|
128
|
+
if (!session_id) return
|
|
129
|
+
const entry = this._entries.get(session_id)
|
|
130
|
+
if (!entry) return
|
|
131
|
+
this._entries.delete(session_id)
|
|
132
|
+
try {
|
|
133
|
+
entry.watcher.stop()
|
|
134
|
+
} catch {
|
|
135
|
+
/* ignore */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async _resolveProjectsRoot() {
|
|
140
|
+
try {
|
|
141
|
+
const r = this.getProjectsRoot?.()
|
|
142
|
+
return r && typeof r.then === "function" ? await r : r
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** TTL を過ぎた watcher を停止・削除する (unviewing 取りこぼし対策)。 */
|
|
149
|
+
_sweep() {
|
|
150
|
+
const now = Date.now()
|
|
151
|
+
for (const [session_id, entry] of this._entries) {
|
|
152
|
+
if (entry.expiresAt <= now) {
|
|
153
|
+
this._entries.delete(session_id)
|
|
154
|
+
try {
|
|
155
|
+
entry.watcher.stop()
|
|
156
|
+
} catch {
|
|
157
|
+
/* ignore */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 全 watcher と sweep timer を停止する。 */
|
|
164
|
+
stop() {
|
|
165
|
+
if (this._sweepTimer) {
|
|
166
|
+
clearInterval(this._sweepTimer)
|
|
167
|
+
this._sweepTimer = null
|
|
168
|
+
}
|
|
169
|
+
for (const entry of this._entries.values()) {
|
|
170
|
+
try {
|
|
171
|
+
entry.watcher.stop()
|
|
172
|
+
} catch {
|
|
173
|
+
/* ignore */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
this._entries.clear()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** テスト用: 現在追従中の session_id 数。 */
|
|
180
|
+
get size() {
|
|
181
|
+
return this._entries.size
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default JsonlLiveWatchers
|
package/src/main.mjs
CHANGED
|
@@ -47,10 +47,12 @@ import {
|
|
|
47
47
|
listSessions as listTmuxSessions,
|
|
48
48
|
listWorktreeNameHistory,
|
|
49
49
|
listWorktreeStubs,
|
|
50
|
+
rebindClaudeSession,
|
|
50
51
|
removeWorktree as removeWorktreeDir,
|
|
51
52
|
} from "./tmux.mjs"
|
|
52
53
|
import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
|
|
53
54
|
import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
|
|
55
|
+
import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
|
|
54
56
|
import {
|
|
55
57
|
contextWindowSize,
|
|
56
58
|
getSessionUsages,
|
|
@@ -439,6 +441,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
439
441
|
await tuiViewerRegistry.start()
|
|
440
442
|
ctx.tuiViewerRegistry = tuiViewerRegistry
|
|
441
443
|
|
|
444
|
+
// jsonl ライブ追従 (T04784 逐次表示): TUI チャットの閲覧ハートビートを起点に対象
|
|
445
|
+
// session の jsonl を tail し、追記行を claude.jsonl.event として逐次 push する。
|
|
446
|
+
// これで session.event 合図 → 全体再 hydrate + 2.5s ポーリングの「どさっと」を廃せる。
|
|
447
|
+
const jsonlLiveWatchers = new JsonlLiveWatchers({
|
|
448
|
+
send: (obj) => client.send(obj),
|
|
449
|
+
getProjectsRoot: getActiveProjectsRoot,
|
|
450
|
+
logger,
|
|
451
|
+
})
|
|
452
|
+
jsonlLiveWatchers.start()
|
|
453
|
+
ctx.jsonlLiveWatchers = jsonlLiveWatchers
|
|
454
|
+
|
|
442
455
|
const shutdown = async (signal) => {
|
|
443
456
|
logger.info({ signal }, "shutting down")
|
|
444
457
|
await runHookBroadcast(plugins, "onAgentStop", ctx)
|
|
@@ -446,6 +459,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
446
459
|
sessionEventLoop?.stop?.()
|
|
447
460
|
tuiPermissionBridge.stop()
|
|
448
461
|
tuiViewerRegistry.stop()
|
|
462
|
+
jsonlLiveWatchers.stop()
|
|
449
463
|
ptyBridge.shutdown()
|
|
450
464
|
claudeBridge?.shutdown?.()
|
|
451
465
|
client.stop()
|
|
@@ -1032,13 +1046,83 @@ async function dispatch(msg, ctx) {
|
|
|
1032
1046
|
ctx.tuiViewerRegistry
|
|
1033
1047
|
?.note({ session_id: msg.session_id, cwd: msg.cwd })
|
|
1034
1048
|
.catch(() => {})
|
|
1049
|
+
// T04784 逐次表示: 閲覧中だけ jsonl を tail して claude.jsonl.event を push。
|
|
1050
|
+
ctx.jsonlLiveWatchers
|
|
1051
|
+
?.note({ session_id: msg.session_id, cwd: msg.cwd })
|
|
1052
|
+
.catch(() => {})
|
|
1035
1053
|
return
|
|
1036
1054
|
case "claude.tui.unviewing":
|
|
1037
1055
|
// 閲覧終了。session_id マーカーを即失効させ、以降の Bash を即 ask に倒す。
|
|
1038
1056
|
ctx.tuiViewerRegistry
|
|
1039
1057
|
?.unview({ session_id: msg.session_id })
|
|
1040
1058
|
.catch(() => {})
|
|
1059
|
+
// jsonl tail も即停止 (閲覧していない session を追従し続けない)。
|
|
1060
|
+
ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
|
|
1061
|
+
return
|
|
1062
|
+
case "claude.tui.bind": {
|
|
1063
|
+
// T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
|
|
1064
|
+
// 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
|
|
1065
|
+
// 2. その session の SDK を完全停止 (二重ホスト/枠流出を防ぐ)。
|
|
1066
|
+
// 3. 裏の tmux claude を `--resume <id>` で同じ会話に載せ替える (read=write 一致)。
|
|
1067
|
+
// ベストエフォート。失敗時は frontend が従来 newest 表示に degrade する。
|
|
1068
|
+
const request_id = msg.request_id
|
|
1069
|
+
const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
|
|
1070
|
+
const sessionName =
|
|
1071
|
+
typeof msg.session_name === "string" ? msg.session_name : ""
|
|
1072
|
+
const reply = (payload) =>
|
|
1073
|
+
ctx.client.send({
|
|
1074
|
+
type: "claude.tui.bind.result",
|
|
1075
|
+
request_id,
|
|
1076
|
+
...payload,
|
|
1077
|
+
})
|
|
1078
|
+
try {
|
|
1079
|
+
// 1) 載せ替え先 session_id を確定。
|
|
1080
|
+
let targetId =
|
|
1081
|
+
typeof msg.session_id === "string" && msg.session_id
|
|
1082
|
+
? msg.session_id
|
|
1083
|
+
: null
|
|
1084
|
+
if (!targetId && cwd) {
|
|
1085
|
+
const projectsRoot = await getActiveProjectsRoot()
|
|
1086
|
+
const { sessions } = await listSessions({
|
|
1087
|
+
cwd,
|
|
1088
|
+
projectsRoot,
|
|
1089
|
+
limit: 1,
|
|
1090
|
+
logger,
|
|
1091
|
+
})
|
|
1092
|
+
targetId = sessions?.[0]?.session_id || null
|
|
1093
|
+
}
|
|
1094
|
+
// 2) SDK を停止 (cwd 全体 + 対象 id)。
|
|
1095
|
+
let stoppedSdk = 0
|
|
1096
|
+
if (ctx.claudeBridge) {
|
|
1097
|
+
stoppedSdk = ctx.claudeBridge.stopSessionsFor({
|
|
1098
|
+
cwd,
|
|
1099
|
+
session_id: targetId || undefined,
|
|
1100
|
+
})
|
|
1101
|
+
}
|
|
1102
|
+
// 3) tmux claude を --resume で載せ替え (targetId があるときだけ)。
|
|
1103
|
+
let rebind = { ok: false }
|
|
1104
|
+
if (targetId && sessionName) {
|
|
1105
|
+
rebind = await rebindClaudeSession(sessionName, targetId, {
|
|
1106
|
+
model: ctx.config?.claude_model || "",
|
|
1107
|
+
permissionMode: ctx.config?.claude_permission_mode || "",
|
|
1108
|
+
logger,
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1111
|
+
reply({
|
|
1112
|
+
session_id: targetId,
|
|
1113
|
+
stopped_sdk: stoppedSdk,
|
|
1114
|
+
rebound: !!rebind.ok,
|
|
1115
|
+
error: rebind.ok ? undefined : rebind.error,
|
|
1116
|
+
})
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
logger?.warn(
|
|
1119
|
+
{ err: err?.message, sessionName, cwd },
|
|
1120
|
+
"claude.tui.bind failed",
|
|
1121
|
+
)
|
|
1122
|
+
reply({ session_id: null, error: err?.message || String(err) })
|
|
1123
|
+
}
|
|
1041
1124
|
return
|
|
1125
|
+
}
|
|
1042
1126
|
case "claude.mcp.status":
|
|
1043
1127
|
case "claude.mcp.reconnect":
|
|
1044
1128
|
case "claude.mcp.authenticate":
|
package/src/tmux.mjs
CHANGED
|
@@ -679,3 +679,115 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
679
679
|
await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
|
|
680
680
|
}
|
|
681
681
|
}
|
|
682
|
+
|
|
683
|
+
/** session_id として安全な形だけ許可する (send-keys へ流すためコマンド注入を防ぐ)。
|
|
684
|
+
* Claude の session_id は UUID (ASCII 英数 + ハイフン) なので、それ以外は弾く。 */
|
|
685
|
+
export function isSafeSessionId(sessionId) {
|
|
686
|
+
return typeof sessionId === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(sessionId)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** agent 設定 (model / permissionMode) を claude のフラグ文字列に変換する ("" or "--model X ...")。 */
|
|
690
|
+
export function composeClaudeFlags(opts = {}) {
|
|
691
|
+
const model = (opts.model || "").trim()
|
|
692
|
+
const mode = (opts.permissionMode || "").trim()
|
|
693
|
+
const flags = []
|
|
694
|
+
if (model) flags.push(`--model ${model}`)
|
|
695
|
+
if (mode) flags.push(`--permission-mode ${mode}`)
|
|
696
|
+
return flags.join(" ")
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* TUI 載せ替え用の `claude --resume <id> [flags]` コマンド文字列を組み立てる (純粋関数)。
|
|
701
|
+
* session_id を明示 resume することで、表示(read)と書込(write)の jsonl を一致させる
|
|
702
|
+
* (`--continue` は解決後 id 不明で read/write が乖離しうるため不可)。
|
|
703
|
+
*
|
|
704
|
+
* @param {string} sessionId
|
|
705
|
+
* @param {{model?:string,permissionMode?:string}} [opts]
|
|
706
|
+
* @returns {string}
|
|
707
|
+
* @throws session_id が安全形でなければ throw (コマンド注入防止)
|
|
708
|
+
*/
|
|
709
|
+
export function buildResumeCmd(sessionId, opts = {}) {
|
|
710
|
+
if (!isSafeSessionId(sessionId)) {
|
|
711
|
+
throw new Error(`unsafe session_id for resume: ${String(sessionId)}`)
|
|
712
|
+
}
|
|
713
|
+
const flags = composeClaudeFlags(opts)
|
|
714
|
+
return `claude --resume ${sessionId}${flags ? " " + flags : ""}`.trim()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
718
|
+
|
|
719
|
+
/** pane_current_command が claude でなくなる (= シェルに戻った) のを待つ。 */
|
|
720
|
+
async function _waitPaneLeftClaude(name, opts = {}, timeoutMs = 4000) {
|
|
721
|
+
const bin = tmuxBin(opts)
|
|
722
|
+
const start = Date.now()
|
|
723
|
+
while (Date.now() - start < timeoutMs) {
|
|
724
|
+
try {
|
|
725
|
+
const { stdout } = await execFileP(bin, [
|
|
726
|
+
"display-message",
|
|
727
|
+
"-p",
|
|
728
|
+
"-t",
|
|
729
|
+
name,
|
|
730
|
+
"#{pane_current_command}",
|
|
731
|
+
])
|
|
732
|
+
const cmd = (stdout || "").trim().toLowerCase()
|
|
733
|
+
if (cmd && !cmd.includes("claude")) return true
|
|
734
|
+
} catch {
|
|
735
|
+
/* ignore */
|
|
736
|
+
}
|
|
737
|
+
await _delay(200)
|
|
738
|
+
}
|
|
739
|
+
return false
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* tmux セッションの対話 claude を、指定 session_id に `--resume` で載せ替える
|
|
744
|
+
* (T04784 TUI resume binding)。
|
|
745
|
+
*
|
|
746
|
+
* 手順 (in-pane 再起動。PTY/attach は維持するので browser の隠し端末は繋がったまま):
|
|
747
|
+
* 1. Ctrl-C を数回送り、生成中断 → claude REPL を抜けてシェルへ戻す
|
|
748
|
+
* (claude は空入力 Ctrl-C 2 回で exit する)。
|
|
749
|
+
* 2. pane_current_command が claude でなくなるのを待つ (最大 timeoutMs)。
|
|
750
|
+
* 3. 行頭をクリア (C-u) してから `claude --resume <id> [flags]` を送って起動。
|
|
751
|
+
*
|
|
752
|
+
* ベストエフォート。失敗しても throw せず {ok:false} を返し、上位は newest 表示に degrade する。
|
|
753
|
+
*
|
|
754
|
+
* @param {string} name tmux セッション名
|
|
755
|
+
* @param {string} sessionId 載せ替え先 Claude session_id
|
|
756
|
+
* @param {{model?:string,permissionMode?:string,logger?:object,tmuxBin?:string}} [opts]
|
|
757
|
+
* @returns {Promise<{ok:boolean, leftClaude?:boolean, cmd?:string, error?:string}>}
|
|
758
|
+
*/
|
|
759
|
+
export async function rebindClaudeSession(name, sessionId, opts = {}) {
|
|
760
|
+
let cmd
|
|
761
|
+
try {
|
|
762
|
+
cmd = buildResumeCmd(sessionId, opts) // session_id 検証もここで実施
|
|
763
|
+
} catch (err) {
|
|
764
|
+
return { ok: false, error: err?.message || String(err) }
|
|
765
|
+
}
|
|
766
|
+
const bin = tmuxBin(opts)
|
|
767
|
+
const send = (...keys) =>
|
|
768
|
+
execFileP(bin, ["send-keys", "-t", name, ...keys]).catch(() => {})
|
|
769
|
+
try {
|
|
770
|
+
// 1) 生成中断 + REPL 終了 (Ctrl-C 連打)。
|
|
771
|
+
for (let i = 0; i < 4; i++) {
|
|
772
|
+
await send("C-c")
|
|
773
|
+
await _delay(180)
|
|
774
|
+
}
|
|
775
|
+
// 2) シェルに戻るのを待つ。
|
|
776
|
+
const leftClaude = await _waitPaneLeftClaude(name, opts, 4000)
|
|
777
|
+
// 3) 行をクリアして resume コマンドを起動。
|
|
778
|
+
await send("C-u")
|
|
779
|
+
await _delay(80)
|
|
780
|
+
await execFileP(bin, ["send-keys", "-t", name, cmd, "Enter"])
|
|
781
|
+
opts.logger?.info(
|
|
782
|
+
{ session: name, resume: sessionId, leftClaude },
|
|
783
|
+
"tui rebind: relaunched claude with --resume",
|
|
784
|
+
)
|
|
785
|
+
return { ok: true, leftClaude, cmd }
|
|
786
|
+
} catch (err) {
|
|
787
|
+
opts.logger?.warn(
|
|
788
|
+
{ session: name, resume: sessionId, err: err?.message },
|
|
789
|
+
"tui rebind failed",
|
|
790
|
+
)
|
|
791
|
+
return { ok: false, error: err?.message || String(err) }
|
|
792
|
+
}
|
|
793
|
+
}
|