@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/main.mjs ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * hub-agent daemon 本体。
3
+ *
4
+ * - 設定読み込み (~/.hub/agent.json) → WsClient 起動
5
+ * - plugin discovery + onAgentStart broadcast
6
+ * - WS メッセージのディスパッチ:
7
+ * - pty.attach / pty.data / pty.resize / pty.detach → PtyBridge
8
+ * - pty 出力 → WsClient で type=pty.data として送信
9
+ * - tmux.exec は Sprint E で tmux module に渡す
10
+ *
11
+ * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
12
+ */
13
+ import pino from "pino"
14
+
15
+ import { readConfig } from "./config.mjs"
16
+ import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
17
+ import { WsClient } from "./ws-client.mjs"
18
+ import { PtyBridge } from "./pty-bridge.mjs"
19
+ import { listSkills } from "./skills.mjs"
20
+ import { listSessionStates } from "./state.mjs"
21
+ import {
22
+ createSession as createTmuxSession,
23
+ execTmux,
24
+ killManySessions,
25
+ killSession as killTmuxSession,
26
+ listSessions as listTmuxSessions,
27
+ } from "./tmux.mjs"
28
+ import { getSessionUsages, getUsage } from "./usage.mjs"
29
+
30
+ const logger = pino({ name: "hub-agent" })
31
+
32
+ export async function startDaemon({ version, ptyModule } = {}) {
33
+ const config = await readConfig()
34
+ if (!config) {
35
+ throw new Error(
36
+ "~/.hub/agent.json が見つかりません。`hub-agent enroll <token>` を先に実行してください。",
37
+ )
38
+ }
39
+
40
+ const plugins = await loadPlugins(logger)
41
+ const ctx = { logger, config, plugins }
42
+
43
+ await runHookBroadcast(plugins, "onAgentStart", ctx)
44
+
45
+ const resolvedPty = ptyModule || (await import("node-pty"))
46
+ const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
47
+
48
+ const client = new WsClient(config, { logger, version })
49
+
50
+ // EventEmitter の 'error' は listener が無いと process が落ちる。
51
+ // ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
52
+ client.on("error", (err) => {
53
+ logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
54
+ })
55
+
56
+ ptyBridge.on("output", ({ stream_id, data }) => {
57
+ client.send({ type: "pty.data", stream_id, data })
58
+ })
59
+ ptyBridge.on("exit", ({ stream_id, code }) => {
60
+ client.send({ type: "pty.exit", stream_id, code })
61
+ })
62
+
63
+ client.on("message", async (msg) => {
64
+ await dispatch(msg, { ...ctx, client, ptyBridge })
65
+ })
66
+
67
+ client.connect()
68
+
69
+ // 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
70
+ // browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
71
+ const stateLoop = startStateLoop({ client, plugins, logger, intervalMs: 5_000 })
72
+
73
+ const shutdown = async (signal) => {
74
+ logger.info({ signal }, "shutting down")
75
+ await runHookBroadcast(plugins, "onAgentStop", ctx)
76
+ stateLoop.stop()
77
+ ptyBridge.shutdown()
78
+ client.stop()
79
+ process.exit(0)
80
+ }
81
+ process.on("SIGINT", () => shutdown("SIGINT"))
82
+ process.on("SIGTERM", () => shutdown("SIGTERM"))
83
+
84
+ return { client, plugins, ptyBridge }
85
+ }
86
+
87
+ /**
88
+ * 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
89
+ *
90
+ * - 前回値との比較で status / context_pct のどちらかが変化したら送る
91
+ * - 新規 session は無条件 push、消えた session は無視 (browser 側で
92
+ * pty.exit 受信時に処理する)
93
+ * - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
94
+ */
95
+ function startStateLoop({ client, plugins, logger, intervalMs }) {
96
+ const lastByName = new Map() // session_name → {status, context_pct}
97
+ let stopped = false
98
+
99
+ const tick = async () => {
100
+ if (stopped) return
101
+ try {
102
+ const states = await listSessionStates({ plugins, logger })
103
+ for (const s of states) {
104
+ const prev = lastByName.get(s.session_name)
105
+ if (
106
+ !prev ||
107
+ prev.status !== s.status ||
108
+ prev.context_pct !== s.context_pct
109
+ ) {
110
+ lastByName.set(s.session_name, {
111
+ status: s.status,
112
+ context_pct: s.context_pct,
113
+ })
114
+ client.send({
115
+ type: "session.state",
116
+ session_name: s.session_name,
117
+ status: s.status,
118
+ context_pct: s.context_pct,
119
+ })
120
+ }
121
+ }
122
+ } catch (err) {
123
+ logger?.warn({ err: err.message }, "state loop tick failed")
124
+ }
125
+ }
126
+
127
+ // 起動直後に 1 回 + 以後 intervalMs 周期
128
+ const t0 = setTimeout(tick, 500)
129
+ const ti = setInterval(tick, intervalMs)
130
+ ti.unref?.()
131
+ t0.unref?.()
132
+
133
+ return {
134
+ stop() {
135
+ stopped = true
136
+ clearInterval(ti)
137
+ clearTimeout(t0)
138
+ },
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Hub からのメッセージを type で分岐する。
144
+ *
145
+ * `pty.*` は PtyBridge にそのまま流す。`tmux.exec` は Sprint E で tmux 抽象に
146
+ * 渡す予定なので、現状は plugin hook chain だけ走らせて log に残す。
147
+ */
148
+ async function dispatch(msg, ctx) {
149
+ const t = msg?.type || ""
150
+ try {
151
+ switch (t) {
152
+ case "ready":
153
+ ctx.logger.info({ msg }, "hub ready")
154
+ return
155
+ case "ack":
156
+ ctx.logger.debug({ echo: msg.echo }, "hub ack")
157
+ return
158
+ case "error":
159
+ ctx.logger.warn({ msg }, "hub error")
160
+ return
161
+ case "pty.attach": {
162
+ const stream_id = msg.stream_id
163
+ try {
164
+ const info = await ctx.ptyBridge.attach({
165
+ stream_id,
166
+ sessionName: msg.session_name || "",
167
+ cols: msg.cols,
168
+ rows: msg.rows,
169
+ })
170
+ ctx.client.send({
171
+ type: "pty.ready",
172
+ stream_id,
173
+ session_name: msg.session_name || "",
174
+ plugin: info.plugin,
175
+ })
176
+ } catch (err) {
177
+ ctx.client.send({
178
+ type: "pty.exit",
179
+ stream_id,
180
+ code: -1,
181
+ error: err.message,
182
+ })
183
+ }
184
+ return
185
+ }
186
+ case "pty.data":
187
+ ctx.ptyBridge.write({ stream_id: msg.stream_id, data: msg.data })
188
+ return
189
+ case "pty.resize":
190
+ ctx.ptyBridge.resize({
191
+ stream_id: msg.stream_id,
192
+ cols: msg.cols,
193
+ rows: msg.rows,
194
+ })
195
+ return
196
+ case "pty.detach":
197
+ ctx.ptyBridge.detach({ stream_id: msg.stream_id })
198
+ return
199
+ case "tmux.exec": {
200
+ const args = Array.isArray(msg.args) ? msg.args : []
201
+ const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
202
+ logger: ctx.logger,
203
+ args,
204
+ env: process.env,
205
+ })
206
+ let result
207
+ if (hookResult?.result?.command) {
208
+ // plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
209
+ const { command, args: spawnArgs, env } = hookResult.result
210
+ try {
211
+ const { execFile } = await import("node:child_process")
212
+ const { promisify } = await import("node:util")
213
+ const execFileP = promisify(execFile)
214
+ const { stdout, stderr } = await execFileP(command, spawnArgs, {
215
+ env: env || process.env,
216
+ maxBuffer: 4 * 1024 * 1024,
217
+ })
218
+ result = { stdout, stderr, exit_code: 0 }
219
+ } catch (err) {
220
+ result = {
221
+ stdout: err.stdout || "",
222
+ stderr: err.stderr || err.message || "",
223
+ exit_code: typeof err.code === "number" ? err.code : 1,
224
+ }
225
+ }
226
+ } else {
227
+ result = await execTmux(args)
228
+ }
229
+ ctx.client.send({
230
+ type: "tmux.result",
231
+ request_id: msg.request_id,
232
+ stdout: result.stdout,
233
+ stderr: result.stderr,
234
+ exit_code: result.exit_code,
235
+ plugin: hookResult?.plugin || null,
236
+ })
237
+ return
238
+ }
239
+ case "tmux.list_sessions": {
240
+ try {
241
+ const sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
242
+ ctx.client.send({
243
+ type: "tmux.sessions",
244
+ request_id: msg.request_id,
245
+ sessions,
246
+ })
247
+ } catch (err) {
248
+ ctx.client.send({
249
+ type: "tmux.sessions",
250
+ request_id: msg.request_id,
251
+ sessions: [],
252
+ error: err.message,
253
+ })
254
+ }
255
+ return
256
+ }
257
+ case "tmux.create_session": {
258
+ const name = (msg.session_name || "").trim()
259
+ const cwd = (msg.cwd || process.env.HOME || "/").trim()
260
+ if (!name) {
261
+ ctx.client.send({
262
+ type: "tmux.create_session.result",
263
+ request_id: msg.request_id,
264
+ ok: false,
265
+ error: "session_name is required",
266
+ })
267
+ return
268
+ }
269
+ try {
270
+ await createTmuxSession(name, cwd, {
271
+ claudeCmd: typeof msg.claude_cmd === "string" ? msg.claude_cmd : undefined,
272
+ })
273
+ ctx.client.send({
274
+ type: "tmux.create_session.result",
275
+ request_id: msg.request_id,
276
+ ok: true,
277
+ session_name: name,
278
+ })
279
+ } catch (err) {
280
+ ctx.client.send({
281
+ type: "tmux.create_session.result",
282
+ request_id: msg.request_id,
283
+ ok: false,
284
+ error: err.message,
285
+ })
286
+ }
287
+ return
288
+ }
289
+ case "tmux.kill_session": {
290
+ const names = Array.isArray(msg.session_names)
291
+ ? msg.session_names
292
+ : msg.session_name
293
+ ? [msg.session_name]
294
+ : []
295
+ if (names.length === 0) {
296
+ ctx.client.send({
297
+ type: "tmux.kill_session.result",
298
+ request_id: msg.request_id,
299
+ killed: [],
300
+ failed: [{ name: "", reason: "session_name(s) required" }],
301
+ })
302
+ return
303
+ }
304
+ const r = await killManySessions(names)
305
+ ctx.client.send({
306
+ type: "tmux.kill_session.result",
307
+ request_id: msg.request_id,
308
+ killed: r.killed,
309
+ failed: r.failed,
310
+ })
311
+ return
312
+ }
313
+ case "skills.request": {
314
+ try {
315
+ const skills = await listSkills({ sessionName: msg.session_name })
316
+ ctx.client.send({
317
+ type: "skills.response",
318
+ request_id: msg.request_id,
319
+ session_name: msg.session_name || "",
320
+ skills,
321
+ })
322
+ } catch (err) {
323
+ ctx.client.send({
324
+ type: "skills.response",
325
+ request_id: msg.request_id,
326
+ session_name: msg.session_name || "",
327
+ skills: [],
328
+ error: err.message,
329
+ })
330
+ }
331
+ return
332
+ }
333
+ case "usage.request": {
334
+ try {
335
+ const [usage, sessions] = await Promise.all([
336
+ getUsage(),
337
+ getSessionUsages(),
338
+ ])
339
+ ctx.client.send({
340
+ type: "usage.response",
341
+ request_id: msg.request_id,
342
+ usage,
343
+ sessions,
344
+ })
345
+ } catch (err) {
346
+ ctx.client.send({
347
+ type: "usage.response",
348
+ request_id: msg.request_id,
349
+ usage: null,
350
+ sessions: [],
351
+ error: err.message,
352
+ })
353
+ }
354
+ return
355
+ }
356
+ default:
357
+ ctx.logger.debug({ type: t }, "unknown message type")
358
+ }
359
+ } catch (err) {
360
+ ctx.logger.error({ type: t, err: err.message }, "dispatch threw")
361
+ }
362
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `hub-agent plugins install <name>` / `uninstall <name>` の実装 (Sprint K)。
3
+ *
4
+ * リポ同梱の `plugins/<name>/` を `~/.hub/plugins/<name>/` にコピーする。
5
+ * uninstall は `~/.hub/plugins/<name>/` を rm -rf する。
6
+ *
7
+ * config.json は plugin によって user 編集が必要なため、existing なら上書き
8
+ * しない (force オプションで強制可)。
9
+ */
10
+ import { promises as fs } from "node:fs"
11
+ import path from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ import { paths as configPaths } from "./config.mjs"
15
+
16
+ function repoPluginsDir() {
17
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins")
18
+ }
19
+
20
+ export async function listInstallableExamples() {
21
+ const dir = repoPluginsDir()
22
+ let entries
23
+ try {
24
+ entries = await fs.readdir(dir, { withFileTypes: true })
25
+ } catch {
26
+ return []
27
+ }
28
+ const out = []
29
+ for (const e of entries) {
30
+ if (!e.isDirectory()) continue
31
+ const pluginFile = path.join(dir, e.name, "plugin.mjs")
32
+ try {
33
+ await fs.access(pluginFile)
34
+ out.push(e.name)
35
+ } catch {
36
+ /* plugin.mjs 無いディレクトリは skip */
37
+ }
38
+ }
39
+ return out
40
+ }
41
+
42
+ async function copyDir(src, dest, { skipExisting = [] } = {}) {
43
+ await fs.mkdir(dest, { recursive: true, mode: 0o700 })
44
+ const entries = await fs.readdir(src, { withFileTypes: true })
45
+ for (const e of entries) {
46
+ const s = path.join(src, e.name)
47
+ const d = path.join(dest, e.name)
48
+ if (e.isDirectory()) {
49
+ await copyDir(s, d, { skipExisting })
50
+ } else {
51
+ if (skipExisting.includes(e.name)) {
52
+ try {
53
+ await fs.access(d)
54
+ continue // 既存ならコピーしない (user 編集を尊重)
55
+ } catch {
56
+ /* fall through to copy */
57
+ }
58
+ }
59
+ await fs.copyFile(s, d)
60
+ }
61
+ }
62
+ }
63
+
64
+ /** @returns {Promise<{ source: string, dest: string, copied: boolean }>} */
65
+ export async function installPlugin(name, { force = false } = {}) {
66
+ if (!name || typeof name !== "string") {
67
+ throw new Error("plugin name is required")
68
+ }
69
+ const src = path.join(repoPluginsDir(), name)
70
+ try {
71
+ const st = await fs.stat(src)
72
+ if (!st.isDirectory()) throw new Error("not a directory")
73
+ } catch {
74
+ const available = await listInstallableExamples()
75
+ const hint = available.length ? ` Available: ${available.join(", ")}` : ""
76
+ throw new Error(`plugin "${name}" not found in repo plugins/.${hint}`)
77
+ }
78
+ const dest = path.join(configPaths.pluginsDir, name)
79
+ if (!force) {
80
+ try {
81
+ await fs.access(dest)
82
+ throw new Error(
83
+ `${dest} already exists. Pass --force to overwrite (config.json は常に温存)`,
84
+ )
85
+ } catch (err) {
86
+ if (err.code !== "ENOENT") {
87
+ // dest exists かつ force でない
88
+ if (err.message && err.message.includes("already exists")) throw err
89
+ if (err.code !== undefined) throw err
90
+ }
91
+ }
92
+ }
93
+ // config.json は既存なら skip して user 編集を温存
94
+ await copyDir(src, dest, { skipExisting: ["config.json"] })
95
+ return { source: src, dest, copied: true }
96
+ }
97
+
98
+ export async function uninstallPlugin(name) {
99
+ if (!name || typeof name !== "string") {
100
+ throw new Error("plugin name is required")
101
+ }
102
+ const dest = path.join(configPaths.pluginsDir, name)
103
+ try {
104
+ await fs.rm(dest, { recursive: true, force: true })
105
+ } catch (err) {
106
+ throw new Error(`failed to remove ${dest}: ${err.message}`)
107
+ }
108
+ return { dest, removed: true }
109
+ }
110
+
111
+ export const _internal = { repoPluginsDir, copyDir }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `~/.hub/plugins/<prio>-<name>/plugin.mjs` を discovery し、hook chain を構築する。
3
+ *
4
+ * - ディレクトリ名の `<prio>` 数字昇順で並ぶ
5
+ * - 各 plugin の default export が `{ name, hooks }` を持つ
6
+ * - `runHookChain(name, ctx)` は最初に non-null を返した hook の戻り値を採用
7
+ *
8
+ * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
9
+ */
10
+ import { promises as fs } from "node:fs"
11
+ import path from "node:path"
12
+ import { pathToFileURL } from "node:url"
13
+
14
+ import { paths } from "./config.mjs"
15
+
16
+ const PLUGIN_DIR_REGEX = /^(\d+)-(.+)$/
17
+
18
+ /**
19
+ * Plugins discovery。読み込み順は dirname の数値昇順。
20
+ *
21
+ * @returns {Promise<import('./hooks.mjs').Plugin[]>}
22
+ */
23
+ export async function loadPlugins(logger) {
24
+ const pluginsDir = paths.pluginsDir
25
+ let entries
26
+ try {
27
+ entries = await fs.readdir(pluginsDir, { withFileTypes: true })
28
+ } catch (err) {
29
+ if (err.code === "ENOENT") return []
30
+ throw err
31
+ }
32
+
33
+ const candidates = entries
34
+ .filter((d) => d.isDirectory())
35
+ .map((d) => {
36
+ const m = PLUGIN_DIR_REGEX.exec(d.name)
37
+ if (!m) return null
38
+ return { dir: d.name, prio: Number(m[1]), name: m[2] }
39
+ })
40
+ .filter(Boolean)
41
+ .sort((a, b) => a.prio - b.prio)
42
+
43
+ const plugins = []
44
+ for (const c of candidates) {
45
+ const pluginFile = path.join(pluginsDir, c.dir, "plugin.mjs")
46
+ // plugin.mjs が存在しないディレクトリ (空 stub, README only 等) は警告のみで skip。
47
+ // import 例外を always-error にすると Sprint K 前の段階的展開で煩い。
48
+ try {
49
+ await fs.access(pluginFile)
50
+ } catch {
51
+ logger?.warn({ pluginFile }, "plugin.mjs not found (skipped)")
52
+ continue
53
+ }
54
+ try {
55
+ const mod = await import(pathToFileURL(pluginFile).href)
56
+ const plugin = mod.default
57
+ if (!plugin || typeof plugin !== "object" || !plugin.hooks) {
58
+ logger?.warn({ pluginFile }, "plugin shape invalid (default export must be { hooks })")
59
+ continue
60
+ }
61
+ plugin.name = plugin.name || c.name
62
+ plugin._prio = c.prio
63
+ plugins.push(plugin)
64
+ logger?.info({ plugin: plugin.name, prio: c.prio }, "plugin loaded")
65
+ } catch (err) {
66
+ logger?.error({ pluginFile, err: err.message }, "plugin load failed")
67
+ }
68
+ }
69
+ return plugins
70
+ }
71
+
72
+ /**
73
+ * 指定 hook を chain 実行し、最初に non-null を返した結果を採用する。
74
+ * すべて null なら null を返す (= caller 側で default 実装に fall through)。
75
+ */
76
+ export async function runHookChain(plugins, hookName, ctx) {
77
+ for (const plugin of plugins) {
78
+ const hook = plugin.hooks?.[hookName]
79
+ if (typeof hook !== "function") continue
80
+ try {
81
+ const result = await hook(ctx)
82
+ if (result !== null && result !== undefined) {
83
+ return { plugin: plugin.name, result }
84
+ }
85
+ } catch (err) {
86
+ ctx?.logger?.error?.({ plugin: plugin.name, hookName, err: err.message }, "hook threw")
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * void を返す hook (onAgentStart / onAgentStop) を全 plugin で実行する。
94
+ */
95
+ export async function runHookBroadcast(plugins, hookName, ctx) {
96
+ for (const plugin of plugins) {
97
+ const hook = plugin.hooks?.[hookName]
98
+ if (typeof hook !== "function") continue
99
+ try {
100
+ await hook(ctx)
101
+ } catch (err) {
102
+ ctx?.logger?.error?.({ plugin: plugin.name, hookName, err: err.message }, "hook threw")
103
+ }
104
+ }
105
+ }