@cocorograph/hub-agent 0.4.1
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/LICENSE +5 -0
- package/README.md +108 -0
- package/bin/hub-agent.mjs +201 -0
- package/package.json +56 -0
- package/plugins/10-tailscale-remote/README.md +71 -0
- package/plugins/10-tailscale-remote/config.example.json +6 -0
- package/plugins/10-tailscale-remote/plugin.mjs +99 -0
- package/scripts/fix-node-pty-perms.mjs +55 -0
- package/scripts/install.sh +130 -0
- package/src/config.mjs +76 -0
- package/src/enroll.mjs +75 -0
- package/src/hooks.mjs +68 -0
- package/src/main.mjs +362 -0
- package/src/plugin-install.mjs +111 -0
- package/src/plugin-loader.mjs +105 -0
- package/src/pty-bridge.mjs +176 -0
- package/src/service-install.mjs +144 -0
- package/src/skills.mjs +142 -0
- package/src/state.mjs +125 -0
- package/src/tmux.mjs +181 -0
- package/src/usage.mjs +288 -0
- package/src/ws-client.mjs +194 -0
- package/templates/co.cocorograph.hub-agent.plist +46 -0
- package/templates/hub-agent.service +18 -0
package/src/tmux.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux サブプロセス呼び出し抽象 (Sprint E)。
|
|
3
|
+
*
|
|
4
|
+
* - `execTmux(args)`: raw 実行 (stdout / stderr / exit_code を返す)
|
|
5
|
+
* - `listSessions()`: 構造化された TmuxSession[] を返す。各 session に
|
|
6
|
+
* status (state.mjs の detectSessionState) と cwd (active pane の current_path)
|
|
7
|
+
* を並列で付与
|
|
8
|
+
* - `createSession(name, cwd, opts)`: detached new-session + CLAUDE_CMD を
|
|
9
|
+
* send-keys。既存ならエラー throw
|
|
10
|
+
* - `killSession(name)`: 1 session を kill。存在しない場合は 'session not found' throw
|
|
11
|
+
* - `killManySessions(names)`: 複数 kill、存在しないものは無視
|
|
12
|
+
*
|
|
13
|
+
* 移植元: D00000_cockpit/webapp/lib/tmux.ts
|
|
14
|
+
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from "node:child_process"
|
|
17
|
+
import { promisify } from "node:util"
|
|
18
|
+
|
|
19
|
+
import { detectSessionState } from "./state.mjs"
|
|
20
|
+
|
|
21
|
+
const execFileP = promisify(execFile)
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TMUX_BIN = "tmux"
|
|
24
|
+
const DEFAULT_CLAUDE_CMD =
|
|
25
|
+
process.env.HUB_CLAUDE_CMD ||
|
|
26
|
+
"claude --continue --model claude-opus-4-7 --permission-mode auto || claude --model claude-opus-4-7 --permission-mode auto"
|
|
27
|
+
|
|
28
|
+
function tmuxBin(opts = {}) {
|
|
29
|
+
return opts.tmuxBin || DEFAULT_TMUX_BIN
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 汎用 tmux 実行。`tmux.exec` メッセージから呼び出される。
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<{stdout: string, stderr: string, exit_code: number}>}
|
|
36
|
+
*/
|
|
37
|
+
export async function execTmux(args, opts = {}) {
|
|
38
|
+
if (!Array.isArray(args)) throw new TypeError("execTmux requires args: string[]")
|
|
39
|
+
try {
|
|
40
|
+
const { stdout, stderr } = await execFileP(tmuxBin(opts), args, {
|
|
41
|
+
env: opts.env || process.env,
|
|
42
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
43
|
+
})
|
|
44
|
+
return { stdout, stderr, exit_code: 0 }
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return {
|
|
47
|
+
stdout: err.stdout || "",
|
|
48
|
+
stderr: err.stderr || err.message || "",
|
|
49
|
+
exit_code: typeof err.code === "number" ? err.code : 1,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getSessionCwd(name, opts = {}) {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execFileP(tmuxBin(opts), [
|
|
57
|
+
"display-message",
|
|
58
|
+
"-p",
|
|
59
|
+
"-t",
|
|
60
|
+
`${name}:`,
|
|
61
|
+
"-F",
|
|
62
|
+
"#{pane_current_path}",
|
|
63
|
+
])
|
|
64
|
+
const s = stdout.trim()
|
|
65
|
+
return s || null
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TMUX_LIST_FIELDS = [
|
|
72
|
+
"#{session_name}",
|
|
73
|
+
"#{session_windows}",
|
|
74
|
+
"#{session_created}",
|
|
75
|
+
"#{session_attached}",
|
|
76
|
+
"#{session_activity}",
|
|
77
|
+
"#{session_last_attached}",
|
|
78
|
+
].join("\t")
|
|
79
|
+
|
|
80
|
+
export async function listSessions(opts = {}) {
|
|
81
|
+
let stdout = ""
|
|
82
|
+
try {
|
|
83
|
+
const r = await execFileP(tmuxBin(opts), [
|
|
84
|
+
"list-sessions",
|
|
85
|
+
"-F",
|
|
86
|
+
TMUX_LIST_FIELDS,
|
|
87
|
+
])
|
|
88
|
+
stdout = r.stdout
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const msg = err?.message || String(err)
|
|
91
|
+
if (msg.includes("no server running") || msg.includes("no current session")) {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
throw err
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const base = stdout
|
|
98
|
+
.split("\n")
|
|
99
|
+
.filter((line) => line.trim().length > 0)
|
|
100
|
+
.map((line) => {
|
|
101
|
+
const [name, windows, created, attached, activity, lastAttached] = line.split("\t")
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
windows: Number(windows),
|
|
105
|
+
created: Number(created),
|
|
106
|
+
attached: attached !== "0",
|
|
107
|
+
activity: Number(activity) || 0,
|
|
108
|
+
lastAttached: Number(lastAttached) || 0,
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// state + cwd を並列付与
|
|
113
|
+
return Promise.all(
|
|
114
|
+
base.map(async (s) => {
|
|
115
|
+
const [state, cwd] = await Promise.all([
|
|
116
|
+
detectSessionState(s.name, opts),
|
|
117
|
+
getSessionCwd(s.name, opts),
|
|
118
|
+
])
|
|
119
|
+
return {
|
|
120
|
+
...s,
|
|
121
|
+
status: state.status,
|
|
122
|
+
context_pct: state.context_pct,
|
|
123
|
+
cwd,
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function killSession(name, opts = {}) {
|
|
130
|
+
try {
|
|
131
|
+
await execFileP(tmuxBin(opts), ["kill-session", "-t", name])
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const msg = err?.message || String(err)
|
|
134
|
+
if (
|
|
135
|
+
msg.includes("can't find session") ||
|
|
136
|
+
msg.includes("no current session") ||
|
|
137
|
+
msg.includes("no server running")
|
|
138
|
+
) {
|
|
139
|
+
throw new Error("session not found")
|
|
140
|
+
}
|
|
141
|
+
throw err
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function killManySessions(names, opts = {}) {
|
|
146
|
+
const killed = []
|
|
147
|
+
const failed = []
|
|
148
|
+
for (const n of names) {
|
|
149
|
+
try {
|
|
150
|
+
await killSession(n, opts)
|
|
151
|
+
killed.push(n)
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const msg = err?.message || String(err)
|
|
154
|
+
if (msg === "session not found") continue
|
|
155
|
+
failed.push({ name: n, reason: msg })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { killed, failed }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 新規 session を detached で作成して claude を起動する。
|
|
163
|
+
* - 同名 session が既にあれば 'duplicate session' throw
|
|
164
|
+
* - opts.claudeCmd で send-keys 内容を上書き可 (空文字なら claude 自動起動しない)
|
|
165
|
+
*/
|
|
166
|
+
export async function createSession(name, cwd, opts = {}) {
|
|
167
|
+
// 既存チェック
|
|
168
|
+
try {
|
|
169
|
+
await execFileP(tmuxBin(opts), ["has-session", "-t", name])
|
|
170
|
+
throw new Error("duplicate session")
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const msg = err?.message || String(err)
|
|
173
|
+
if (msg === "duplicate session") throw err
|
|
174
|
+
// has-session が非 0 = セッション無し
|
|
175
|
+
}
|
|
176
|
+
await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", cwd])
|
|
177
|
+
const claudeCmd = opts.claudeCmd ?? DEFAULT_CLAUDE_CMD
|
|
178
|
+
if (claudeCmd) {
|
|
179
|
+
await execFileP(tmuxBin(opts), ["send-keys", "-t", name, claudeCmd, "Enter"])
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/usage.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code の usage 集計 (Sprint H part 3)。
|
|
3
|
+
*
|
|
4
|
+
* 優先順位:
|
|
5
|
+
* 1. `~/.hub/usage/latest.json` (Claude statusLine 2.1.80+ の公式値)
|
|
6
|
+
* 2. `~/.claude/projects/<proj>/<uuid>.jsonl` の `assistant.message.usage`
|
|
7
|
+
* を 5h / 7d で集計したフォールバック (推定値)
|
|
8
|
+
*
|
|
9
|
+
* 環境変数で override:
|
|
10
|
+
* - HUB_USAGE_CACHE : statusLine cache のパス
|
|
11
|
+
* - HUB_USAGE_SESSIONS_DIR : session 別 statusLine ディレクトリ
|
|
12
|
+
* - HUB_CLAUDE_PROJECTS_DIR : ~/.claude/projects のパス差し替え
|
|
13
|
+
* - HUB_PLAN : pro / max_5x / max_20x (default max_5x)
|
|
14
|
+
* - HUB_LIMIT_5H / HUB_LIMIT_7D : limit 上書き
|
|
15
|
+
*
|
|
16
|
+
* 移植元: D00000_cockpit/webapp/lib/usage.ts
|
|
17
|
+
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from "node:fs"
|
|
20
|
+
import os from "node:os"
|
|
21
|
+
import path from "node:path"
|
|
22
|
+
|
|
23
|
+
function configPath(envKey, ...fallback) {
|
|
24
|
+
return process.env[envKey] || path.join(...fallback)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function projectsDir() {
|
|
28
|
+
return configPath(
|
|
29
|
+
"HUB_CLAUDE_PROJECTS_DIR",
|
|
30
|
+
os.homedir(),
|
|
31
|
+
".claude",
|
|
32
|
+
"projects",
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function statuslineCache() {
|
|
37
|
+
return configPath(
|
|
38
|
+
"HUB_USAGE_CACHE",
|
|
39
|
+
os.homedir(),
|
|
40
|
+
".hub",
|
|
41
|
+
"usage",
|
|
42
|
+
"latest.json",
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function statuslineSessionsDir() {
|
|
47
|
+
return configPath(
|
|
48
|
+
"HUB_USAGE_SESSIONS_DIR",
|
|
49
|
+
os.homedir(),
|
|
50
|
+
".hub",
|
|
51
|
+
"usage",
|
|
52
|
+
"sessions",
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SESSION_STALE_MS = 15 * 60 * 1000
|
|
57
|
+
|
|
58
|
+
const PLAN_LIMITS = {
|
|
59
|
+
pro: { msg5h: 45, msg7d: 315 },
|
|
60
|
+
max_5x: { msg5h: 280, msg7d: 1960 },
|
|
61
|
+
max_20x: { msg5h: 1120, msg7d: 7840 },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function planLimits() {
|
|
65
|
+
const key = process.env.HUB_PLAN || "max_5x"
|
|
66
|
+
const base = PLAN_LIMITS[key] || PLAN_LIMITS.max_5x
|
|
67
|
+
return {
|
|
68
|
+
plan: key,
|
|
69
|
+
limit5h: Number(process.env.HUB_LIMIT_5H) || base.msg5h,
|
|
70
|
+
limit7d: Number(process.env.HUB_LIMIT_7D) || base.msg7d,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function emptyBucket(limit) {
|
|
75
|
+
return {
|
|
76
|
+
tokens: 0,
|
|
77
|
+
messages: 0,
|
|
78
|
+
limit,
|
|
79
|
+
percent: 0,
|
|
80
|
+
oldestTsMs: null,
|
|
81
|
+
resetAtMs: null,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emptyStats(planKey, limit5h, limit7d, now) {
|
|
86
|
+
return {
|
|
87
|
+
plan: planKey,
|
|
88
|
+
computedAt: now,
|
|
89
|
+
source: "estimate",
|
|
90
|
+
context: null,
|
|
91
|
+
last5h: emptyBucket(limit5h),
|
|
92
|
+
last7d: emptyBucket(limit7d),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readOrNull(p) {
|
|
97
|
+
try {
|
|
98
|
+
return await fs.readFile(p, "utf-8")
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readOfficial(now) {
|
|
105
|
+
const text = await readOrNull(statuslineCache())
|
|
106
|
+
if (!text) return null
|
|
107
|
+
let j
|
|
108
|
+
try {
|
|
109
|
+
j = JSON.parse(text)
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
const rl = j.rate_limits ?? j.rateLimits ?? {}
|
|
114
|
+
const five = rl.five_hour ?? rl.fiveHour ?? rl["5h"]
|
|
115
|
+
const seven = rl.seven_day ?? rl.sevenDay ?? rl["7d"]
|
|
116
|
+
if (!five && !seven) return null
|
|
117
|
+
const ctx = j.context_window?.used_percentage ?? j.contextWindow?.used_percentage ?? null
|
|
118
|
+
|
|
119
|
+
const bucketize = (b, defaultLimit) => {
|
|
120
|
+
if (!b) return { ...emptyBucket(defaultLimit), limit: defaultLimit }
|
|
121
|
+
const percent = typeof b.used_percentage === "number" ? b.used_percentage : 0
|
|
122
|
+
let resetMs = null
|
|
123
|
+
if (typeof b.resets_at === "number") {
|
|
124
|
+
resetMs = b.resets_at < 1e12 ? b.resets_at * 1000 : b.resets_at
|
|
125
|
+
} else if (typeof b.resets_at === "string") {
|
|
126
|
+
const t = Date.parse(b.resets_at)
|
|
127
|
+
if (!Number.isNaN(t)) resetMs = t
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
tokens: 0,
|
|
131
|
+
messages: 0,
|
|
132
|
+
limit: b.limit ?? defaultLimit,
|
|
133
|
+
percent,
|
|
134
|
+
oldestTsMs: null,
|
|
135
|
+
resetAtMs: resetMs,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
plan: j.plan || "official",
|
|
141
|
+
computedAt: now,
|
|
142
|
+
source: "official",
|
|
143
|
+
context: typeof ctx === "number" ? ctx : null,
|
|
144
|
+
last5h: bucketize(five, 100),
|
|
145
|
+
last7d: bucketize(seven, 100),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readEstimate(now) {
|
|
150
|
+
const { plan, limit5h, limit7d } = planLimits()
|
|
151
|
+
const projects = await fs.readdir(projectsDir()).catch(() => null)
|
|
152
|
+
if (!projects) return emptyStats(plan, limit5h, limit7d, now)
|
|
153
|
+
|
|
154
|
+
const t5h = now - 5 * 60 * 60 * 1000
|
|
155
|
+
const t7d = now - 7 * 24 * 60 * 60 * 1000
|
|
156
|
+
const WIN_5H = 5 * 60 * 60 * 1000
|
|
157
|
+
const WIN_7D = 7 * 24 * 60 * 60 * 1000
|
|
158
|
+
|
|
159
|
+
let tokens5h = 0
|
|
160
|
+
let msgs5h = 0
|
|
161
|
+
let tokens7d = 0
|
|
162
|
+
let msgs7d = 0
|
|
163
|
+
let oldest5h = null
|
|
164
|
+
let oldest7d = null
|
|
165
|
+
|
|
166
|
+
await Promise.all(
|
|
167
|
+
projects.map(async (p) => {
|
|
168
|
+
const dir = path.join(projectsDir(), p)
|
|
169
|
+
const files = await fs.readdir(dir).catch(() => [])
|
|
170
|
+
for (const f of files) {
|
|
171
|
+
if (!f.endsWith(".jsonl")) continue
|
|
172
|
+
const fp = path.join(dir, f)
|
|
173
|
+
try {
|
|
174
|
+
const st = await fs.stat(fp)
|
|
175
|
+
if (st.mtimeMs < t7d) continue
|
|
176
|
+
} catch {
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
const text = await readOrNull(fp)
|
|
180
|
+
if (!text) continue
|
|
181
|
+
for (const line of text.split("\n")) {
|
|
182
|
+
if (!line || line.length < 50) continue
|
|
183
|
+
if (!line.includes('"usage"')) continue
|
|
184
|
+
let d
|
|
185
|
+
try {
|
|
186
|
+
d = JSON.parse(line)
|
|
187
|
+
} catch {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
if (d.type !== "assistant") continue
|
|
191
|
+
const ts = d.timestamp ? Date.parse(d.timestamp) : 0
|
|
192
|
+
if (!ts || ts < t7d) continue
|
|
193
|
+
const u = d.message?.usage
|
|
194
|
+
if (!u) continue
|
|
195
|
+
const tok = (u.output_tokens || 0) + (u.input_tokens || 0)
|
|
196
|
+
tokens7d += tok
|
|
197
|
+
msgs7d += 1
|
|
198
|
+
if (oldest7d === null || ts < oldest7d) oldest7d = ts
|
|
199
|
+
if (ts >= t5h) {
|
|
200
|
+
tokens5h += tok
|
|
201
|
+
msgs5h += 1
|
|
202
|
+
if (oldest5h === null || ts < oldest5h) oldest5h = ts
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
plan,
|
|
211
|
+
computedAt: Date.now(),
|
|
212
|
+
source: "estimate",
|
|
213
|
+
context: null,
|
|
214
|
+
last5h: {
|
|
215
|
+
tokens: tokens5h,
|
|
216
|
+
messages: msgs5h,
|
|
217
|
+
limit: limit5h,
|
|
218
|
+
percent: limit5h > 0 ? Math.round((msgs5h / limit5h) * 1000) / 10 : 0,
|
|
219
|
+
oldestTsMs: oldest5h,
|
|
220
|
+
resetAtMs: oldest5h !== null ? oldest5h + WIN_5H : null,
|
|
221
|
+
},
|
|
222
|
+
last7d: {
|
|
223
|
+
tokens: tokens7d,
|
|
224
|
+
messages: msgs7d,
|
|
225
|
+
limit: limit7d,
|
|
226
|
+
percent: limit7d > 0 ? Math.round((msgs7d / limit7d) * 1000) / 10 : 0,
|
|
227
|
+
oldestTsMs: oldest7d,
|
|
228
|
+
resetAtMs: oldest7d !== null ? oldest7d + WIN_7D : null,
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 5h / 7d 集計の UsageStats を返す。statusLine が無い環境では transcript
|
|
235
|
+
* から推定する。
|
|
236
|
+
*/
|
|
237
|
+
export async function getUsage() {
|
|
238
|
+
const now = Date.now()
|
|
239
|
+
const official = await readOfficial(now)
|
|
240
|
+
if (official) return official
|
|
241
|
+
return readEstimate(now)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* statusLine が session_id 単位で残した JSON を読み、生きているものだけ返す。
|
|
246
|
+
* cwd と context_percent を返す。フロントで「この tmux session で動いている
|
|
247
|
+
* Claude のコンテキスト残量」表示に使う。
|
|
248
|
+
*/
|
|
249
|
+
export async function getSessionUsages() {
|
|
250
|
+
const now = Date.now()
|
|
251
|
+
const files = await fs.readdir(statuslineSessionsDir()).catch(() => null)
|
|
252
|
+
if (!files) return []
|
|
253
|
+
const out = []
|
|
254
|
+
await Promise.all(
|
|
255
|
+
files.map(async (f) => {
|
|
256
|
+
if (!f.endsWith(".json")) return
|
|
257
|
+
const fp = path.join(statuslineSessionsDir(), f)
|
|
258
|
+
let mtime = 0
|
|
259
|
+
try {
|
|
260
|
+
const st = await fs.stat(fp)
|
|
261
|
+
mtime = st.mtimeMs
|
|
262
|
+
if (now - mtime > SESSION_STALE_MS) return
|
|
263
|
+
} catch {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const text = await readOrNull(fp)
|
|
267
|
+
if (!text) return
|
|
268
|
+
let j
|
|
269
|
+
try {
|
|
270
|
+
j = JSON.parse(text)
|
|
271
|
+
} catch {
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
const ctx = j.context_window?.used_percentage ?? j.contextWindow?.used_percentage
|
|
275
|
+
const cwd =
|
|
276
|
+
j.workspace?.project_dir || j.workspace?.current_dir || j.cwd
|
|
277
|
+
const sid = j.session_id
|
|
278
|
+
if (typeof ctx !== "number" || !cwd || !sid) return
|
|
279
|
+
out.push({
|
|
280
|
+
sessionId: sid,
|
|
281
|
+
cwd,
|
|
282
|
+
contextPercent: ctx,
|
|
283
|
+
updatedAtMs: mtime,
|
|
284
|
+
})
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub の `/ws/cockpit/agent/` への outbound WSS クライアント。
|
|
3
|
+
*
|
|
4
|
+
* - 接続: `Authorization: Bearer <agent_id>:<agent_token>`
|
|
5
|
+
* - 起動時に `hello`、30s おきに `heartbeat` を送信
|
|
6
|
+
* - 切断時は exponential backoff (1s, 2s, 4s, ..., max 30s) で再接続
|
|
7
|
+
* - サーバから受け取った JSON は `onMessage` callback に渡す
|
|
8
|
+
*
|
|
9
|
+
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from "node:events"
|
|
12
|
+
import os from "node:os"
|
|
13
|
+
|
|
14
|
+
import WebSocket from "ws"
|
|
15
|
+
|
|
16
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
17
|
+
const MIN_BACKOFF_MS = 1_000
|
|
18
|
+
const MAX_BACKOFF_MS = 30_000
|
|
19
|
+
|
|
20
|
+
export class WsClient extends EventEmitter {
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
23
|
+
* @param {{ logger?: import('pino').Logger, version?: string }} opts
|
|
24
|
+
*/
|
|
25
|
+
constructor(config, opts = {}) {
|
|
26
|
+
super()
|
|
27
|
+
this.config = config
|
|
28
|
+
this.logger = opts.logger
|
|
29
|
+
this.version = opts.version || "0.1.0"
|
|
30
|
+
this.hostname = opts.hostname || os.hostname()
|
|
31
|
+
this.ws = null
|
|
32
|
+
this.heartbeatTimer = null
|
|
33
|
+
this.reconnectTimer = null
|
|
34
|
+
this.backoff = MIN_BACKOFF_MS
|
|
35
|
+
this.stopped = false
|
|
36
|
+
this.startedAt = Date.now()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** WSS 接続を開始する。`stop()` まで自動で reconnect 続行。 */
|
|
40
|
+
connect() {
|
|
41
|
+
if (this.stopped) return
|
|
42
|
+
const wsUrl = this._buildWsUrl()
|
|
43
|
+
this.logger?.info({ wsUrl }, "ws connecting")
|
|
44
|
+
|
|
45
|
+
const headers = {
|
|
46
|
+
Authorization: `Bearer ${this.config.agent_id}:${this.config.agent_token}`,
|
|
47
|
+
}
|
|
48
|
+
const ws = new WebSocket(wsUrl, { headers })
|
|
49
|
+
this.ws = ws
|
|
50
|
+
|
|
51
|
+
ws.on("open", () => {
|
|
52
|
+
this.backoff = MIN_BACKOFF_MS
|
|
53
|
+
this.logger?.info("ws open")
|
|
54
|
+
this._sendJson({
|
|
55
|
+
type: "hello",
|
|
56
|
+
agent_id: this.config.agent_id,
|
|
57
|
+
hostname: this.hostname,
|
|
58
|
+
version: this.version,
|
|
59
|
+
})
|
|
60
|
+
this._startHeartbeat()
|
|
61
|
+
this.emit("open")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
ws.on("message", (data) => {
|
|
65
|
+
let msg
|
|
66
|
+
try {
|
|
67
|
+
msg = JSON.parse(data.toString("utf-8"))
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.logger?.warn({ err: err.message }, "ws json parse failed")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
this.emit("message", msg)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
ws.on("close", (code, reason) => {
|
|
76
|
+
this._stopHeartbeat()
|
|
77
|
+
this.logger?.info({ code, reason: reason?.toString() }, "ws close")
|
|
78
|
+
this.emit("close", { code, reason })
|
|
79
|
+
if (!this.stopped) this._scheduleReconnect()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
ws.on("error", (err) => {
|
|
83
|
+
this.logger?.warn({ err: err.message }, "ws error")
|
|
84
|
+
this.emit("error", err)
|
|
85
|
+
// close もほぼ続けて飛ぶので reconnect 予約は close 側に任せる
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** メッセージを送る。未接続なら no-op (logger.warn)。 */
|
|
90
|
+
send(obj) {
|
|
91
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
92
|
+
this.logger?.warn({ type: obj?.type }, "ws send skipped (not open)")
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
return this._sendJson(obj)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Reconnect を止めて切断する。 */
|
|
99
|
+
stop() {
|
|
100
|
+
this.stopped = true
|
|
101
|
+
this._stopHeartbeat()
|
|
102
|
+
if (this.reconnectTimer) {
|
|
103
|
+
clearTimeout(this.reconnectTimer)
|
|
104
|
+
this.reconnectTimer = null
|
|
105
|
+
}
|
|
106
|
+
if (this.ws) {
|
|
107
|
+
try {
|
|
108
|
+
this.ws.close(1000, "client_stop")
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------
|
|
116
|
+
// internals
|
|
117
|
+
// ------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
_buildWsUrl() {
|
|
120
|
+
const hub = this.config.hub_url.replace(/\/+$/, "")
|
|
121
|
+
const scheme = hub.startsWith("https:") ? "wss:" : "ws:"
|
|
122
|
+
const host = hub.replace(/^https?:\/\//, "")
|
|
123
|
+
return `${scheme}//${host}/ws/cockpit/agent/`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_sendJson(obj) {
|
|
127
|
+
try {
|
|
128
|
+
this.ws.send(JSON.stringify(obj))
|
|
129
|
+
return true
|
|
130
|
+
} catch (err) {
|
|
131
|
+
this.logger?.warn({ err: err.message }, "ws send failed")
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_startHeartbeat() {
|
|
137
|
+
this._stopHeartbeat()
|
|
138
|
+
this.heartbeatTimer = setInterval(() => {
|
|
139
|
+
// 送信失敗 (= ws not OPEN) を検知したら即時 close + reconnect 発火。
|
|
140
|
+
// setInterval を放置すると死んだコネクションに heartbeat を投げ続けて
|
|
141
|
+
// 30s 間気付かないので、close を強制トリガーする。
|
|
142
|
+
const ok = this._sendJson({
|
|
143
|
+
type: "heartbeat",
|
|
144
|
+
uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
145
|
+
})
|
|
146
|
+
if (!ok) {
|
|
147
|
+
this.logger?.warn("heartbeat send failed, forcing reconnect")
|
|
148
|
+
this._forceReconnect()
|
|
149
|
+
}
|
|
150
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
151
|
+
// unref して node プロセスが timer 1 個で抜けられなくならないように
|
|
152
|
+
this.heartbeatTimer.unref?.()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_stopHeartbeat() {
|
|
156
|
+
if (this.heartbeatTimer) {
|
|
157
|
+
clearInterval(this.heartbeatTimer)
|
|
158
|
+
this.heartbeatTimer = null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_forceReconnect() {
|
|
163
|
+
// 現在の ws を強制 close。close ハンドラが _scheduleReconnect を呼ぶので
|
|
164
|
+
// それ以上の処理は不要。stopped 中は no-op。
|
|
165
|
+
// ws 実装によって terminate (ws npm package) と close (browser WebSocket
|
|
166
|
+
// 互換) どちらが生えているか異なるので両対応する。
|
|
167
|
+
if (this.stopped) return
|
|
168
|
+
if (!this.ws) return
|
|
169
|
+
if (this.ws.readyState === WebSocket.CLOSED) return
|
|
170
|
+
try {
|
|
171
|
+
if (typeof this.ws.terminate === "function") {
|
|
172
|
+
this.ws.terminate()
|
|
173
|
+
} else if (typeof this.ws.close === "function") {
|
|
174
|
+
this.ws.close(1006, "heartbeat_failed")
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_scheduleReconnect() {
|
|
182
|
+
// exponential backoff + ±20% jitter で同時接続が同期しないようにする。
|
|
183
|
+
const base = this.backoff
|
|
184
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1)
|
|
185
|
+
const delay = Math.max(MIN_BACKOFF_MS, Math.round(base + jitter))
|
|
186
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS)
|
|
187
|
+
this.logger?.info({ delayMs: delay, nextBaseMs: this.backoff }, "ws reconnect scheduled")
|
|
188
|
+
this.reconnectTimer = setTimeout(() => {
|
|
189
|
+
this.reconnectTimer = null
|
|
190
|
+
this.connect()
|
|
191
|
+
}, delay)
|
|
192
|
+
this.reconnectTimer.unref?.()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
+
<plist version="1.0">
|
|
5
|
+
<dict>
|
|
6
|
+
<key>Label</key>
|
|
7
|
+
<string>co.cocorograph.hub-agent</string>
|
|
8
|
+
|
|
9
|
+
<key>ProgramArguments</key>
|
|
10
|
+
<array>
|
|
11
|
+
<string>__HUB_AGENT_BIN__</string>
|
|
12
|
+
<string>start</string>
|
|
13
|
+
</array>
|
|
14
|
+
|
|
15
|
+
<key>RunAtLoad</key>
|
|
16
|
+
<true/>
|
|
17
|
+
|
|
18
|
+
<key>KeepAlive</key>
|
|
19
|
+
<dict>
|
|
20
|
+
<key>SuccessfulExit</key>
|
|
21
|
+
<false/>
|
|
22
|
+
<key>NetworkState</key>
|
|
23
|
+
<true/>
|
|
24
|
+
</dict>
|
|
25
|
+
|
|
26
|
+
<key>StandardOutPath</key>
|
|
27
|
+
<string>__HOME__/.hub/agent.log</string>
|
|
28
|
+
<key>StandardErrorPath</key>
|
|
29
|
+
<string>__HOME__/.hub/agent.log</string>
|
|
30
|
+
|
|
31
|
+
<key>WorkingDirectory</key>
|
|
32
|
+
<string>__HOME__</string>
|
|
33
|
+
|
|
34
|
+
<key>EnvironmentVariables</key>
|
|
35
|
+
<dict>
|
|
36
|
+
<key>PATH</key>
|
|
37
|
+
<string>__PATH__</string>
|
|
38
|
+
<key>HOME</key>
|
|
39
|
+
<string>__HOME__</string>
|
|
40
|
+
</dict>
|
|
41
|
+
|
|
42
|
+
<!-- KeepAlive で過剰再起動した時に 10 秒スロットルする -->
|
|
43
|
+
<key>ThrottleInterval</key>
|
|
44
|
+
<integer>10</integer>
|
|
45
|
+
</dict>
|
|
46
|
+
</plist>
|