@cocorograph/hub-agent 0.5.2 → 0.5.4
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 +28 -1
- package/src/tmux.mjs +74 -2
- package/src/ws-client.mjs +4 -1
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
12
12
|
*/
|
|
13
|
+
import { readFile } from "node:fs/promises"
|
|
14
|
+
import os from "node:os"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
|
|
13
17
|
import pino from "pino"
|
|
14
18
|
|
|
15
19
|
import { readConfig } from "./config.mjs"
|
|
@@ -29,6 +33,24 @@ import { getSessionUsages, getUsage } from "./usage.mjs"
|
|
|
29
33
|
|
|
30
34
|
const logger = pino({ name: "hub-agent" })
|
|
31
35
|
|
|
36
|
+
const BUNDLE_MANIFEST_PATH =
|
|
37
|
+
process.env.HUB_BUNDLE_MANIFEST ||
|
|
38
|
+
path.join(os.homedir(), ".claude", "scripts", "manifest.json")
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `~/.claude/scripts/manifest.json` から Hub AI bundle の version を読む。
|
|
42
|
+
* 未セットアップなら null を返す。
|
|
43
|
+
*/
|
|
44
|
+
async function readBundleVersion() {
|
|
45
|
+
try {
|
|
46
|
+
const text = await readFile(BUNDLE_MANIFEST_PATH, "utf-8")
|
|
47
|
+
const data = JSON.parse(text)
|
|
48
|
+
return typeof data?.version === "string" ? data.version : null
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export async function startDaemon({ version, ptyModule } = {}) {
|
|
33
55
|
const config = await readConfig()
|
|
34
56
|
if (!config) {
|
|
@@ -45,7 +67,12 @@ export async function startDaemon({ version, ptyModule } = {}) {
|
|
|
45
67
|
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
46
68
|
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
47
69
|
|
|
48
|
-
const
|
|
70
|
+
const bundleVersion = await readBundleVersion()
|
|
71
|
+
if (bundleVersion) {
|
|
72
|
+
logger.info({ bundleVersion }, "hub bundle version detected")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const client = new WsClient(config, { logger, version, bundleVersion })
|
|
49
76
|
|
|
50
77
|
// EventEmitter の 'error' は listener が無いと process が落ちる。
|
|
51
78
|
// ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
|
package/src/tmux.mjs
CHANGED
|
@@ -14,13 +14,64 @@
|
|
|
14
14
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
15
15
|
*/
|
|
16
16
|
import { execFile } from "node:child_process"
|
|
17
|
+
import fs from "node:fs/promises"
|
|
18
|
+
import os from "node:os"
|
|
19
|
+
import path from "node:path"
|
|
17
20
|
import { promisify } from "node:util"
|
|
18
21
|
|
|
19
22
|
import { detectSessionState } from "./state.mjs"
|
|
23
|
+
import { getSessionUsages } from "./usage.mjs"
|
|
20
24
|
|
|
21
25
|
const execFileP = promisify(execFile)
|
|
22
26
|
|
|
23
27
|
const DEFAULT_TMUX_BIN = "tmux"
|
|
28
|
+
|
|
29
|
+
const HUB_PROJECTS_BASE =
|
|
30
|
+
process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
|
|
31
|
+
|
|
32
|
+
function sanitizeTmuxName(s) {
|
|
33
|
+
return s.replace(/[.:]/g, "-")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `~/hub/projects/<project>/.claude/worktrees/<wt>` を走査し、
|
|
38
|
+
* worktree_session_name → parent_session_name の Map を返す。
|
|
39
|
+
*
|
|
40
|
+
* - 各 worktree dir は `.git` ファイル/ディレクトリの存在で git worktree と判定
|
|
41
|
+
* - session 名はディレクトリ名を sanitize したもの (tmux session 名命名規則)
|
|
42
|
+
*
|
|
43
|
+
* 移植元: D00000_cockpit/webapp/lib/workspaces.ts (findWorktrees)
|
|
44
|
+
*/
|
|
45
|
+
async function buildWorktreeParentMap() {
|
|
46
|
+
const out = new Map()
|
|
47
|
+
let projects
|
|
48
|
+
try {
|
|
49
|
+
projects = await fs.readdir(HUB_PROJECTS_BASE, { withFileTypes: true })
|
|
50
|
+
} catch {
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
for (const p of projects) {
|
|
54
|
+
if (!p.isDirectory() || p.name.startsWith(".")) continue
|
|
55
|
+
const wtBase = path.join(HUB_PROJECTS_BASE, p.name, ".claude", "worktrees")
|
|
56
|
+
let wts
|
|
57
|
+
try {
|
|
58
|
+
wts = await fs.readdir(wtBase, { withFileTypes: true })
|
|
59
|
+
} catch {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
for (const wt of wts) {
|
|
63
|
+
if (!wt.isDirectory() || wt.name.startsWith(".")) continue
|
|
64
|
+
try {
|
|
65
|
+
// .git が存在する = 正規 git worktree
|
|
66
|
+
await fs.stat(path.join(wtBase, wt.name, ".git"))
|
|
67
|
+
} catch {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
out.set(sanitizeTmuxName(wt.name), sanitizeTmuxName(p.name))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
24
75
|
const DEFAULT_CLAUDE_CMD =
|
|
25
76
|
process.env.HUB_CLAUDE_CMD ||
|
|
26
77
|
"claude --continue --model claude-opus-4-7 --permission-mode auto || claude --model claude-opus-4-7 --permission-mode auto"
|
|
@@ -109,18 +160,39 @@ export async function listSessions(opts = {}) {
|
|
|
109
160
|
}
|
|
110
161
|
})
|
|
111
162
|
|
|
112
|
-
// state + cwd を並列付与
|
|
163
|
+
// state + cwd + worktree 親判定 + statusLine 由来 context% を並列付与
|
|
164
|
+
// statusLine の `used_percentage` を context_pct (USED %) として使う。
|
|
165
|
+
// pane 解析 (state.detectSessionState) はフォーマット変動に脆いので、
|
|
166
|
+
// statusLine cache がある cwd では優先採用する。
|
|
167
|
+
const [wtParentMap, sessionUsages] = await Promise.all([
|
|
168
|
+
buildWorktreeParentMap(),
|
|
169
|
+
getSessionUsages().catch(() => []),
|
|
170
|
+
])
|
|
171
|
+
// cwd → context_pct の lookup (同一 cwd で複数 session_id ある場合は最新 mtime 優先)
|
|
172
|
+
const ctxByCwd = new Map()
|
|
173
|
+
for (const u of sessionUsages) {
|
|
174
|
+
const prev = ctxByCwd.get(u.cwd)
|
|
175
|
+
if (!prev || u.updatedAtMs > prev.updatedAtMs) {
|
|
176
|
+
ctxByCwd.set(u.cwd, { contextPercent: u.contextPercent, updatedAtMs: u.updatedAtMs })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
113
180
|
return Promise.all(
|
|
114
181
|
base.map(async (s) => {
|
|
115
182
|
const [state, cwd] = await Promise.all([
|
|
116
183
|
detectSessionState(s.name, opts),
|
|
117
184
|
getSessionCwd(s.name, opts),
|
|
118
185
|
])
|
|
186
|
+
// statusLine 由来 context% を最優先 (USED %), なければ pane 解析の fallback
|
|
187
|
+
const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
|
|
188
|
+
const context_pct =
|
|
189
|
+
typeof fromStatusLine === "number" ? fromStatusLine : state.context_pct
|
|
119
190
|
return {
|
|
120
191
|
...s,
|
|
121
192
|
status: state.status,
|
|
122
|
-
context_pct
|
|
193
|
+
context_pct,
|
|
123
194
|
cwd,
|
|
195
|
+
parent_session_name: wtParentMap.get(s.name) || null,
|
|
124
196
|
}
|
|
125
197
|
}),
|
|
126
198
|
)
|
package/src/ws-client.mjs
CHANGED
|
@@ -20,13 +20,14 @@ const MAX_BACKOFF_MS = 30_000
|
|
|
20
20
|
export class WsClient extends EventEmitter {
|
|
21
21
|
/**
|
|
22
22
|
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
23
|
-
* @param {{ logger?: import('pino').Logger, version?: string }} opts
|
|
23
|
+
* @param {{ logger?: import('pino').Logger, version?: string, bundleVersion?: string|null, hostname?: string }} opts
|
|
24
24
|
*/
|
|
25
25
|
constructor(config, opts = {}) {
|
|
26
26
|
super()
|
|
27
27
|
this.config = config
|
|
28
28
|
this.logger = opts.logger
|
|
29
29
|
this.version = opts.version || "0.1.0"
|
|
30
|
+
this.bundleVersion = opts.bundleVersion || null
|
|
30
31
|
this.hostname = opts.hostname || os.hostname()
|
|
31
32
|
this.ws = null
|
|
32
33
|
this.heartbeatTimer = null
|
|
@@ -56,6 +57,7 @@ export class WsClient extends EventEmitter {
|
|
|
56
57
|
agent_id: this.config.agent_id,
|
|
57
58
|
hostname: this.hostname,
|
|
58
59
|
version: this.version,
|
|
60
|
+
bundle_version: this.bundleVersion,
|
|
59
61
|
})
|
|
60
62
|
this._startHeartbeat()
|
|
61
63
|
this.emit("open")
|
|
@@ -142,6 +144,7 @@ export class WsClient extends EventEmitter {
|
|
|
142
144
|
const ok = this._sendJson({
|
|
143
145
|
type: "heartbeat",
|
|
144
146
|
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
147
|
+
bundle_version: this.bundleVersion,
|
|
145
148
|
})
|
|
146
149
|
if (!ok) {
|
|
147
150
|
this.logger?.warn("heartbeat send failed, forcing reconnect")
|