@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/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>