@cocorograph/hub-agent 0.5.17 → 0.5.19
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 +72 -3
- package/src/service-install.mjs +52 -2
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -137,8 +137,34 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
137
137
|
client.send({ type: "pty.exit", stream_id, code })
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
// Hub からのメッセージ dispatch は **直列実行** する。
|
|
141
|
+
//
|
|
142
|
+
// `EventEmitter.emit` は async リスナーの完了を待たないため、`async (msg) =>
|
|
143
|
+
// await dispatch(msg, ...)` の形だと複数 WS メッセージが連続して届いたとき
|
|
144
|
+
// それぞれの dispatch が並列実行される。`tmux.exec` の `await execTmux`
|
|
145
|
+
// (subprocess 待ち) 中に後続 `pty.data` の同期 `ptyBridge.write` が先行する
|
|
146
|
+
// と、ブラウザ→Hub では順序が保証されていても agent 内部で逆転する。
|
|
147
|
+
//
|
|
148
|
+
// 典型的な被害: cockpit ChatInput の `await onCancelTmuxMode()` → paste
|
|
149
|
+
// の流れで、ブラウザ側が cancel の応答を待ってから paste を送っても、
|
|
150
|
+
// 別経路の `tmux.exec` (state loop の polling 等) で agent が busy だと、
|
|
151
|
+
// cancel の execTmux が完了する前に paste が ptyBridge.write される。
|
|
152
|
+
// 結果として paste が tmux 内部状態 (copy mode 等) を抜ける前に届き、
|
|
153
|
+
// claude TUI に正しく入らない事象が散発する。
|
|
154
|
+
//
|
|
155
|
+
// Promise chain で前の dispatch 完了を待ってから次を始めることで、WS
|
|
156
|
+
// 受信順 = pane 反映順を保証する。pty 出力 (agent → browser) は別経路
|
|
157
|
+
// (`ptyBridge.on("output")`) なのでこの直列化の影響を受けない。
|
|
158
|
+
let dispatchChain = Promise.resolve()
|
|
159
|
+
client.on("message", (msg) => {
|
|
160
|
+
dispatchChain = dispatchChain
|
|
161
|
+
.then(() => dispatch(msg, { ...ctx, client, ptyBridge }))
|
|
162
|
+
.catch((err) => {
|
|
163
|
+
logger.error(
|
|
164
|
+
{ err: err.message, type: msg?.type },
|
|
165
|
+
"dispatch threw in serial queue",
|
|
166
|
+
)
|
|
167
|
+
})
|
|
142
168
|
})
|
|
143
169
|
|
|
144
170
|
client.connect()
|
|
@@ -169,6 +195,40 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
169
195
|
const SESSION_EVENTS_DIR =
|
|
170
196
|
process.env.COCKPIT_SESSION_EVENTS_DIR || "/tmp/cockpit_session_events"
|
|
171
197
|
|
|
198
|
+
/**
|
|
199
|
+
* `/tmp/cockpit_session_events/` 全 .json を読んで session_name → {event, at}
|
|
200
|
+
* の Map を返す (Phase 4: tmux.list_sessions レスポンスに含めて配信する用)。
|
|
201
|
+
*
|
|
202
|
+
* fs.watch 由来の `session.event` push はファイル変更時のみのため、frontend が
|
|
203
|
+
* 新規マウント (ハードリロード等) すると過去 event を取得できず CockpitStatusDot
|
|
204
|
+
* が全グレー表示になる問題があった。これを list 同梱で復元する。
|
|
205
|
+
*/
|
|
206
|
+
async function readAllSessionEvents() {
|
|
207
|
+
const out = new Map()
|
|
208
|
+
let files
|
|
209
|
+
try {
|
|
210
|
+
files = await readdir(SESSION_EVENTS_DIR)
|
|
211
|
+
} catch {
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
if (!f.endsWith(".json")) continue
|
|
216
|
+
const sessionName = f.slice(0, -5)
|
|
217
|
+
try {
|
|
218
|
+
const text = await readFile(path.join(SESSION_EVENTS_DIR, f), "utf-8")
|
|
219
|
+
const data = JSON.parse(text)
|
|
220
|
+
if (!data || typeof data.event !== "string") continue
|
|
221
|
+
out.set(sessionName, {
|
|
222
|
+
event: data.event,
|
|
223
|
+
at: typeof data.at === "number" ? data.at : Date.now(),
|
|
224
|
+
})
|
|
225
|
+
} catch {
|
|
226
|
+
// 一時欠如 / parse 失敗は無視
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
231
|
+
|
|
172
232
|
/**
|
|
173
233
|
* `/tmp/cockpit_session_events/<tmux_session>.json` を fs.watch して
|
|
174
234
|
* UserPromptSubmit / Stop の event を `session.event` 型で Hub に push する。
|
|
@@ -437,10 +497,19 @@ async function dispatch(msg, ctx) {
|
|
|
437
497
|
case "tmux.list_sessions": {
|
|
438
498
|
try {
|
|
439
499
|
const sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
|
|
500
|
+
// 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
|
|
501
|
+
// レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
|
|
502
|
+
// fs.watch 由来の session.event push は揮発性のため、frontend が新規
|
|
503
|
+
// マウントすると過去 event を取れず全グレー表示になる事象を解消する。
|
|
504
|
+
const lastEventByName = await readAllSessionEvents()
|
|
505
|
+
const enriched = sessions.map((s) => ({
|
|
506
|
+
...s,
|
|
507
|
+
last_event: lastEventByName.get(s.name) || null,
|
|
508
|
+
}))
|
|
440
509
|
ctx.client.send({
|
|
441
510
|
type: "tmux.sessions",
|
|
442
511
|
request_id: msg.request_id,
|
|
443
|
-
sessions,
|
|
512
|
+
sessions: enriched,
|
|
444
513
|
})
|
|
445
514
|
} catch (err) {
|
|
446
515
|
ctx.client.send({
|
package/src/service-install.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* テンプレ内の __HUB_AGENT_BIN__ / __HOME__ / __PATH__ を実行時に置換する。
|
|
10
10
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
11
11
|
*/
|
|
12
|
-
import { promises as fs } from "node:fs"
|
|
12
|
+
import { promises as fs, existsSync, realpathSync } from "node:fs"
|
|
13
13
|
import os from "node:os"
|
|
14
14
|
import path from "node:path"
|
|
15
15
|
import { spawnSync } from "node:child_process"
|
|
@@ -63,10 +63,59 @@ function run(cmd, args, opts = {}) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* `which hub-agent` の結果を、launchd / systemd から長期間 exec 可能な
|
|
68
|
+
* 安定パスに正規化する。pure 関数。`opts` で fs を差し替えてテスト可能。
|
|
69
|
+
*
|
|
70
|
+
* 背景: fnm 利用者の `which hub-agent` は現在のシェル専用の一時パス
|
|
71
|
+
* (`~/.local/state/fnm_multishells/<PID>_<ts>/bin/hub-agent`) を返す。
|
|
72
|
+
* それを plist に書き込むと、install-service を叩いたシェル終了後に
|
|
73
|
+
* symlink が消えて launchd が exec できず、agent が無音で停止する事故が
|
|
74
|
+
* 発生する (2026-05-19, シェル PID 4683 で install したまま放置されて
|
|
75
|
+
* 検知が遅れた)。
|
|
76
|
+
*
|
|
77
|
+
* 優先順:
|
|
78
|
+
* 1. fnm default alias `~/.local/share/fnm/aliases/default/bin/hub-agent`
|
|
79
|
+
* が存在すればそれを返す (fnm 利用者にとって最も安定)
|
|
80
|
+
* 2. whichPath が `/fnm_multishells/` を含むなら realpath で
|
|
81
|
+
* `~/.local/share/fnm/node-versions/vX.Y.Z/.../bin/hub-agent.mjs`
|
|
82
|
+
* に展開 (node アップグレード前提でも install-service 再実行で済む)
|
|
83
|
+
* 3. それ以外 (brew / nvm / 通常 PATH) は whichPath をそのまま返す
|
|
84
|
+
*/
|
|
85
|
+
function normalizeBinPath(whichPath, opts = {}) {
|
|
86
|
+
const home = opts.home ?? os.homedir()
|
|
87
|
+
const fileExists = opts.fileExists ?? existsSync
|
|
88
|
+
const resolveReal = opts.realpath ?? realpathSync
|
|
89
|
+
|
|
90
|
+
const fnmDefaultBin = path.join(
|
|
91
|
+
home,
|
|
92
|
+
".local",
|
|
93
|
+
"share",
|
|
94
|
+
"fnm",
|
|
95
|
+
"aliases",
|
|
96
|
+
"default",
|
|
97
|
+
"bin",
|
|
98
|
+
"hub-agent",
|
|
99
|
+
)
|
|
100
|
+
if (fileExists(fnmDefaultBin)) return fnmDefaultBin
|
|
101
|
+
|
|
102
|
+
if (!whichPath) return null
|
|
103
|
+
if (whichPath.includes("/fnm_multishells/")) {
|
|
104
|
+
try {
|
|
105
|
+
return resolveReal(whichPath)
|
|
106
|
+
} catch {
|
|
107
|
+
// realpath 失敗時は whichPath をそのまま返す (既存挙動の保持)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return whichPath
|
|
111
|
+
}
|
|
112
|
+
|
|
66
113
|
/** インストール先の hub-agent CLI のフルパスを返す。`hub-agent` が PATH にある前提。 */
|
|
67
114
|
function detectHubAgentBin() {
|
|
68
115
|
const r = spawnSync("/usr/bin/which", ["hub-agent"], { encoding: "utf-8" })
|
|
69
|
-
|
|
116
|
+
const whichPath = r.status === 0 && r.stdout ? r.stdout.trim() : null
|
|
117
|
+
const normalized = normalizeBinPath(whichPath)
|
|
118
|
+
if (normalized) return normalized
|
|
70
119
|
// fallback: node $repo/bin/hub-agent.mjs
|
|
71
120
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "hub-agent.mjs")
|
|
72
121
|
}
|
|
@@ -179,6 +228,7 @@ export async function uninstallService() {
|
|
|
179
228
|
export const _internal = {
|
|
180
229
|
expandTemplate,
|
|
181
230
|
detectHubAgentBin,
|
|
231
|
+
normalizeBinPath,
|
|
182
232
|
macPlistPath,
|
|
183
233
|
linuxUnitPath,
|
|
184
234
|
repoTemplatesDir,
|