@cocorograph/hub-agent 0.5.6 → 0.5.8
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 +24 -1
- package/src/tmux.mjs +56 -2
- package/src/ws-client.mjs +121 -1
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
12
12
|
*/
|
|
13
|
+
import { readFileSync } from "node:fs"
|
|
13
14
|
import { readFile } from "node:fs/promises"
|
|
14
15
|
import os from "node:os"
|
|
15
16
|
import path from "node:path"
|
|
@@ -53,6 +54,20 @@ async function readBundleVersion() {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* WsClient の runtime refresh / fs.watch コールバックから同期で呼ばれる版。
|
|
59
|
+
* 失敗時は null を返す (silent fail)。
|
|
60
|
+
*/
|
|
61
|
+
function readBundleVersionSync() {
|
|
62
|
+
try {
|
|
63
|
+
const text = readFileSync(BUNDLE_MANIFEST_PATH, "utf-8")
|
|
64
|
+
const data = JSON.parse(text)
|
|
65
|
+
return typeof data?.version === "string" ? data.version : null
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
export async function startDaemon({ version, ptyModule } = {}) {
|
|
57
72
|
const config = await readConfig()
|
|
58
73
|
if (!config) {
|
|
@@ -74,7 +89,13 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
74
89
|
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
const client = new WsClient(config, {
|
|
92
|
+
const client = new WsClient(config, {
|
|
93
|
+
logger,
|
|
94
|
+
version,
|
|
95
|
+
bundleVersion,
|
|
96
|
+
bundleVersionProvider: readBundleVersionSync,
|
|
97
|
+
bundleManifestPath: BUNDLE_MANIFEST_PATH,
|
|
98
|
+
})
|
|
78
99
|
|
|
79
100
|
// EventEmitter の 'error' は listener が無いと process が落ちる。
|
|
80
101
|
// ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
|
|
@@ -298,6 +319,7 @@ async function dispatch(msg, ctx) {
|
|
|
298
319
|
try {
|
|
299
320
|
await createTmuxSession(name, cwd, {
|
|
300
321
|
claudeCmd: typeof msg.claude_cmd === "string" ? msg.claude_cmd : undefined,
|
|
322
|
+
logger: ctx.logger,
|
|
301
323
|
})
|
|
302
324
|
ctx.client.send({
|
|
303
325
|
type: "tmux.create_session.result",
|
|
@@ -342,6 +364,7 @@ async function dispatch(msg, ctx) {
|
|
|
342
364
|
// worktree dir で tmux session を作成 (claude_cmd は createSession の default)
|
|
343
365
|
await createTmuxSession(wtName, wtPath, {
|
|
344
366
|
claudeCmd: typeof msg.claude_cmd === "string" ? msg.claude_cmd : undefined,
|
|
367
|
+
logger: ctx.logger,
|
|
345
368
|
})
|
|
346
369
|
ctx.client.send({
|
|
347
370
|
type: "worktree.create.result",
|
package/src/tmux.mjs
CHANGED
|
@@ -33,6 +33,20 @@ function sanitizeTmuxName(s) {
|
|
|
33
33
|
return s.replace(/[.:]/g, "-")
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 先頭の `~` / `~/` を `os.homedir()` に展開する。
|
|
38
|
+
*
|
|
39
|
+
* tmux は `-c` に渡された cwd をシェル展開しないため、フロントから
|
|
40
|
+
* 受け取った `~/hub/projects/...` を絶対パスに変換する必要がある。
|
|
41
|
+
* 既に絶対パス / 相対パスなら noop。
|
|
42
|
+
*/
|
|
43
|
+
export function expandTilde(p) {
|
|
44
|
+
if (typeof p !== "string" || p.length === 0) return p
|
|
45
|
+
if (p === "~") return os.homedir()
|
|
46
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2))
|
|
47
|
+
return p
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* git branch 名から worktree dir 名 (= tmux session 名) を導出する。
|
|
38
52
|
* 例: "feature/multi-area" -> "feature-multi-area"
|
|
@@ -70,7 +84,10 @@ async function branchExists(repoDir, branch) {
|
|
|
70
84
|
* 移植元: D00000_cockpit/webapp/lib/worktrees.ts (createWorktree)
|
|
71
85
|
*/
|
|
72
86
|
export async function createWorktreeDir(parentDir, branch) {
|
|
73
|
-
|
|
87
|
+
// parentDir に `~/...` 形式が来ても受け付けられるよう先に展開する。
|
|
88
|
+
// 展開後が絶対パスなら path.resolve は HUB_PROJECTS_BASE を無視して
|
|
89
|
+
// そのまま使う (Node の path.resolve 仕様)。
|
|
90
|
+
const repoDir = path.resolve(HUB_PROJECTS_BASE, expandTilde(parentDir))
|
|
74
91
|
if (repoDir !== HUB_PROJECTS_BASE && !repoDir.startsWith(HUB_PROJECTS_BASE + path.sep)) {
|
|
75
92
|
throw new Error("dir outside projects base")
|
|
76
93
|
}
|
|
@@ -302,6 +319,20 @@ export async function killManySessions(names, opts = {}) {
|
|
|
302
319
|
* - opts.claudeCmd で send-keys 内容を上書き可 (空文字なら claude 自動起動しない)
|
|
303
320
|
*/
|
|
304
321
|
export async function createSession(name, cwd, opts = {}) {
|
|
322
|
+
// tmux は `-c` をシェル展開しないため、ここで `~` を絶対パスに展開する。
|
|
323
|
+
// 展開後の cwd は同名引数で以降の検証 / tmux に渡す。
|
|
324
|
+
const resolvedCwd = expandTilde(cwd)
|
|
325
|
+
// cwd 存在チェック (旧 Cockpit 単体実装と同等の防御)
|
|
326
|
+
// 無いまま tmux に渡すと pane の current_path が HOME など別ディレクトリに
|
|
327
|
+
// fallback して、SessionStart hook が project を解決できなくなる。
|
|
328
|
+
try {
|
|
329
|
+
const st = await fs.stat(resolvedCwd)
|
|
330
|
+
if (!st.isDirectory()) throw new Error("cwd not a directory")
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err?.code === "ENOENT") throw new Error(`cwd not found: ${resolvedCwd}`)
|
|
333
|
+
if (err?.message === "cwd not a directory") throw err
|
|
334
|
+
throw new Error(`cwd stat failed: ${err?.message || String(err)}`)
|
|
335
|
+
}
|
|
305
336
|
// 既存チェック
|
|
306
337
|
try {
|
|
307
338
|
await execFileP(tmuxBin(opts), ["has-session", "-t", name])
|
|
@@ -311,7 +342,30 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
311
342
|
if (msg === "duplicate session") throw err
|
|
312
343
|
// has-session が非 0 = セッション無し
|
|
313
344
|
}
|
|
314
|
-
await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c",
|
|
345
|
+
await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
|
|
346
|
+
// 起動直後 pane の current_path を確認し、想定 cwd と異なれば WARN ログを残す
|
|
347
|
+
// (Phase 3: 任意検証。tmux 側のクオート / 権限問題で fallback した場合を検知する)
|
|
348
|
+
if (opts.logger) {
|
|
349
|
+
try {
|
|
350
|
+
const { stdout } = await execFileP(tmuxBin(opts), [
|
|
351
|
+
"display-message",
|
|
352
|
+
"-p",
|
|
353
|
+
"-t",
|
|
354
|
+
`${name}:`,
|
|
355
|
+
"-F",
|
|
356
|
+
"#{pane_current_path}",
|
|
357
|
+
])
|
|
358
|
+
const actual = (stdout || "").trim()
|
|
359
|
+
if (actual && actual !== resolvedCwd) {
|
|
360
|
+
opts.logger.warn(
|
|
361
|
+
{ session: name, requested: resolvedCwd, actual },
|
|
362
|
+
"tmux pane cwd differs from requested cwd",
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// display-message 失敗は致命的でないので飲み込む
|
|
367
|
+
}
|
|
368
|
+
}
|
|
315
369
|
const claudeCmd = opts.claudeCmd ?? DEFAULT_CLAUDE_CMD
|
|
316
370
|
if (claudeCmd) {
|
|
317
371
|
await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
|
package/src/ws-client.mjs
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
* - 起動時に `hello`、30s おきに `heartbeat` を送信
|
|
6
6
|
* - 切断時は exponential backoff (1s, 2s, 4s, ..., max 30s) で再接続
|
|
7
7
|
* - サーバから受け取った JSON は `onMessage` callback に渡す
|
|
8
|
+
* - `bundleVersionProvider` + `bundleManifestPath` を渡すと、manifest.json の
|
|
9
|
+
* 変更を fs.watch で検知して即時 heartbeat を送信し Cockpit UI に最新版を
|
|
10
|
+
* リアルタイム反映する (heartbeat 30s 待ちを回避)
|
|
8
11
|
*
|
|
9
12
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
10
13
|
*/
|
|
11
14
|
import { EventEmitter } from "node:events"
|
|
15
|
+
import fs from "node:fs"
|
|
12
16
|
import os from "node:os"
|
|
13
17
|
|
|
14
18
|
import WebSocket from "ws"
|
|
@@ -16,11 +20,19 @@ import WebSocket from "ws"
|
|
|
16
20
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
17
21
|
const MIN_BACKOFF_MS = 1_000
|
|
18
22
|
const MAX_BACKOFF_MS = 30_000
|
|
23
|
+
const BUNDLE_WATCH_DEBOUNCE_MS = 500
|
|
19
24
|
|
|
20
25
|
export class WsClient extends EventEmitter {
|
|
21
26
|
/**
|
|
22
27
|
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
23
|
-
* @param {{
|
|
28
|
+
* @param {{
|
|
29
|
+
* logger?: import('pino').Logger,
|
|
30
|
+
* version?: string,
|
|
31
|
+
* bundleVersion?: string|null,
|
|
32
|
+
* bundleVersionProvider?: () => (string|null),
|
|
33
|
+
* bundleManifestPath?: string|null,
|
|
34
|
+
* hostname?: string,
|
|
35
|
+
* }} opts
|
|
24
36
|
*/
|
|
25
37
|
constructor(config, opts = {}) {
|
|
26
38
|
super()
|
|
@@ -28,6 +40,13 @@ export class WsClient extends EventEmitter {
|
|
|
28
40
|
this.logger = opts.logger
|
|
29
41
|
this.version = opts.version || "0.1.0"
|
|
30
42
|
this.bundleVersion = opts.bundleVersion || null
|
|
43
|
+
this.bundleVersionProvider =
|
|
44
|
+
typeof opts.bundleVersionProvider === "function"
|
|
45
|
+
? opts.bundleVersionProvider
|
|
46
|
+
: null
|
|
47
|
+
this.bundleManifestPath = opts.bundleManifestPath || null
|
|
48
|
+
this.bundleWatcher = null
|
|
49
|
+
this.bundleWatchDebounceTimer = null
|
|
31
50
|
this.hostname = opts.hostname || os.hostname()
|
|
32
51
|
this.ws = null
|
|
33
52
|
this.heartbeatTimer = null
|
|
@@ -52,6 +71,8 @@ export class WsClient extends EventEmitter {
|
|
|
52
71
|
ws.on("open", () => {
|
|
53
72
|
this.backoff = MIN_BACKOFF_MS
|
|
54
73
|
this.logger?.info("ws open")
|
|
74
|
+
// hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
|
|
75
|
+
this._refreshBundleVersion()
|
|
55
76
|
this._sendJson({
|
|
56
77
|
type: "hello",
|
|
57
78
|
agent_id: this.config.agent_id,
|
|
@@ -60,6 +81,7 @@ export class WsClient extends EventEmitter {
|
|
|
60
81
|
bundle_version: this.bundleVersion,
|
|
61
82
|
})
|
|
62
83
|
this._startHeartbeat()
|
|
84
|
+
this._startBundleWatcher()
|
|
63
85
|
this.emit("open")
|
|
64
86
|
})
|
|
65
87
|
|
|
@@ -76,6 +98,7 @@ export class WsClient extends EventEmitter {
|
|
|
76
98
|
|
|
77
99
|
ws.on("close", (code, reason) => {
|
|
78
100
|
this._stopHeartbeat()
|
|
101
|
+
this._stopBundleWatcher()
|
|
79
102
|
this.logger?.info({ code, reason: reason?.toString() }, "ws close")
|
|
80
103
|
this.emit("close", { code, reason })
|
|
81
104
|
if (!this.stopped) this._scheduleReconnect()
|
|
@@ -101,6 +124,7 @@ export class WsClient extends EventEmitter {
|
|
|
101
124
|
stop() {
|
|
102
125
|
this.stopped = true
|
|
103
126
|
this._stopHeartbeat()
|
|
127
|
+
this._stopBundleWatcher()
|
|
104
128
|
if (this.reconnectTimer) {
|
|
105
129
|
clearTimeout(this.reconnectTimer)
|
|
106
130
|
this.reconnectTimer = null
|
|
@@ -138,6 +162,9 @@ export class WsClient extends EventEmitter {
|
|
|
138
162
|
_startHeartbeat() {
|
|
139
163
|
this._stopHeartbeat()
|
|
140
164
|
this.heartbeatTimer = setInterval(() => {
|
|
165
|
+
// heartbeat 都度 provider 経由で bundle version を最新化する。
|
|
166
|
+
// fs.watch を取り逃した変更 (atomic rename / 別マウント越し等) への保険。
|
|
167
|
+
this._refreshBundleVersion()
|
|
141
168
|
// 送信失敗 (= ws not OPEN) を検知したら即時 close + reconnect 発火。
|
|
142
169
|
// setInterval を放置すると死んだコネクションに heartbeat を投げ続けて
|
|
143
170
|
// 30s 間気付かないので、close を強制トリガーする。
|
|
@@ -162,6 +189,99 @@ export class WsClient extends EventEmitter {
|
|
|
162
189
|
}
|
|
163
190
|
}
|
|
164
191
|
|
|
192
|
+
/**
|
|
193
|
+
* bundleVersionProvider を呼んで `this.bundleVersion` を最新化する。
|
|
194
|
+
* 値が変わったら true を返す。provider が未設定 / 失敗時は false。
|
|
195
|
+
*/
|
|
196
|
+
_refreshBundleVersion() {
|
|
197
|
+
if (!this.bundleVersionProvider) return false
|
|
198
|
+
try {
|
|
199
|
+
const next = this.bundleVersionProvider()
|
|
200
|
+
const normalized = typeof next === "string" && next.length > 0 ? next : null
|
|
201
|
+
if (normalized === this.bundleVersion) return false
|
|
202
|
+
this.logger?.info(
|
|
203
|
+
{ from: this.bundleVersion, to: normalized },
|
|
204
|
+
"bundle version refreshed",
|
|
205
|
+
)
|
|
206
|
+
this.bundleVersion = normalized
|
|
207
|
+
return true
|
|
208
|
+
} catch (err) {
|
|
209
|
+
this.logger?.warn({ err: err.message }, "bundle version refresh failed")
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `bundleManifestPath` を fs.watch で監視し、変更時に即時 heartbeat を送る。
|
|
216
|
+
* Cockpit UI への反映ラグを 30s heartbeat 周期から ~15s (UI poll) に短縮する。
|
|
217
|
+
* fs.watch の起動に失敗しても heartbeat 都度 refresh が保険として動くので no-op。
|
|
218
|
+
*/
|
|
219
|
+
_startBundleWatcher() {
|
|
220
|
+
if (!this.bundleManifestPath || this.bundleWatcher) return
|
|
221
|
+
try {
|
|
222
|
+
this.bundleWatcher = fs.watch(
|
|
223
|
+
this.bundleManifestPath,
|
|
224
|
+
{ persistent: false },
|
|
225
|
+
() => this._onBundleManifestChange(),
|
|
226
|
+
)
|
|
227
|
+
this.bundleWatcher.on("error", (err) => {
|
|
228
|
+
this.logger?.warn(
|
|
229
|
+
{ err: err.message, path: this.bundleManifestPath },
|
|
230
|
+
"bundle manifest watcher errored",
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
this.logger?.debug(
|
|
234
|
+
{ path: this.bundleManifestPath },
|
|
235
|
+
"bundle manifest watcher started",
|
|
236
|
+
)
|
|
237
|
+
} catch (err) {
|
|
238
|
+
this.logger?.warn(
|
|
239
|
+
{ err: err.message, path: this.bundleManifestPath },
|
|
240
|
+
"bundle manifest watcher failed to start",
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_stopBundleWatcher() {
|
|
246
|
+
if (this.bundleWatchDebounceTimer) {
|
|
247
|
+
clearTimeout(this.bundleWatchDebounceTimer)
|
|
248
|
+
this.bundleWatchDebounceTimer = null
|
|
249
|
+
}
|
|
250
|
+
if (this.bundleWatcher) {
|
|
251
|
+
try {
|
|
252
|
+
this.bundleWatcher.close()
|
|
253
|
+
} catch {
|
|
254
|
+
/* ignore */
|
|
255
|
+
}
|
|
256
|
+
this.bundleWatcher = null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* fs.watch のコールバック。同一書き込みで多重 fire することがある (macOS) ため
|
|
262
|
+
* デバウンスしてから refresh + 即時 heartbeat する。
|
|
263
|
+
*/
|
|
264
|
+
_onBundleManifestChange() {
|
|
265
|
+
if (this.bundleWatchDebounceTimer) {
|
|
266
|
+
clearTimeout(this.bundleWatchDebounceTimer)
|
|
267
|
+
}
|
|
268
|
+
this.bundleWatchDebounceTimer = setTimeout(() => {
|
|
269
|
+
this.bundleWatchDebounceTimer = null
|
|
270
|
+
const changed = this._refreshBundleVersion()
|
|
271
|
+
if (!changed) return
|
|
272
|
+
const ok = this._sendJson({
|
|
273
|
+
type: "heartbeat",
|
|
274
|
+
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
275
|
+
bundle_version: this.bundleVersion,
|
|
276
|
+
})
|
|
277
|
+
if (!ok) {
|
|
278
|
+
this.logger?.warn("bundle-change heartbeat send failed, forcing reconnect")
|
|
279
|
+
this._forceReconnect()
|
|
280
|
+
}
|
|
281
|
+
}, BUNDLE_WATCH_DEBOUNCE_MS)
|
|
282
|
+
this.bundleWatchDebounceTimer.unref?.()
|
|
283
|
+
}
|
|
284
|
+
|
|
165
285
|
_forceReconnect() {
|
|
166
286
|
// 現在の ws を強制 close。close ハンドラが _scheduleReconnect を呼ぶので
|
|
167
287
|
// それ以上の処理は不要。stopped 中は no-op。
|