@cocorograph/hub-agent 0.6.79 → 0.6.82

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.79",
3
+ "version": "0.6.82",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/config.mjs CHANGED
@@ -7,10 +7,34 @@
7
7
  * られるように)。`$HUB_AGENT_CONFIG_DIR` を export しておくと、その値が
8
8
  * 優先される (mosh / 専用ユーザー用)。
9
9
  */
10
- import { promises as fs, constants as fsConsts } from "node:fs"
10
+ import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
11
11
  import path from "node:path"
12
12
  import os from "node:os"
13
13
 
14
+ /**
15
+ * 実行プラットフォームを返す。Hub の UI が OS 別コマンドを出し分けるために使う。
16
+ *
17
+ * 重要: Windows のエージェントは WSL2 内で動くため `process.platform` は "linux" を
18
+ * 返す。素の Linux と区別するため、WSL を検出して "wsl" を返す。
19
+ *
20
+ * @returns {"darwin"|"wsl"|"linux"|"win32"|string}
21
+ */
22
+ export function detectPlatform() {
23
+ if (process.platform === "darwin") return "darwin"
24
+ if (process.platform === "win32") return "win32"
25
+ if (process.platform === "linux") {
26
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return "wsl"
27
+ try {
28
+ const v = readFileSync("/proc/version", "utf-8").toLowerCase()
29
+ if (v.includes("microsoft") || v.includes("wsl")) return "wsl"
30
+ } catch {
31
+ /* /proc/version が読めない環境は素の linux 扱い */
32
+ }
33
+ return "linux"
34
+ }
35
+ return process.platform || "unknown"
36
+ }
37
+
14
38
  function resolveConfigDir() {
15
39
  if (process.env.HUB_AGENT_CONFIG_DIR) return process.env.HUB_AGENT_CONFIG_DIR
16
40
  return path.join(os.homedir(), ".hub")
package/src/enroll.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import os from "node:os"
10
10
 
11
- import { hasConfig, writeConfig } from "./config.mjs"
11
+ import { detectPlatform, hasConfig, writeConfig } from "./config.mjs"
12
12
 
13
13
  const DEFAULT_HUB_URL = process.env.HUB_URL || "https://hub.cocorograph.com"
14
14
 
@@ -41,6 +41,7 @@ export async function enroll(enrollmentToken, opts = {}) {
41
41
  enrollment_token: enrollmentToken,
42
42
  hostname,
43
43
  version,
44
+ platform: detectPlatform(),
44
45
  }),
45
46
  })
46
47
 
package/src/main.mjs CHANGED
@@ -19,12 +19,13 @@ import path from "node:path"
19
19
 
20
20
  import pino from "pino"
21
21
 
22
- import { readConfig, writeConfig } from "./config.mjs"
22
+ import { detectPlatform, readConfig, writeConfig } from "./config.mjs"
23
23
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
24
24
  import { WsClient } from "./ws-client.mjs"
25
25
  import { PtyBridge } from "./pty-bridge.mjs"
26
26
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
27
27
  import { UploadManager } from "./claude-upload.mjs"
28
+ import { requestSelfUninstall } from "./service-install.mjs"
28
29
  import {
29
30
  decideSessionRotation,
30
31
  fetchSessionHistory,
@@ -404,6 +405,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
404
405
  const client = new WsClient(config, {
405
406
  logger,
406
407
  version,
408
+ platform: detectPlatform(),
407
409
  bundleVersion,
408
410
  bundleVersionProvider: readBundleVersionSync,
409
411
  bundleManifestPath: BUNDLE_MANIFEST_PATH,
@@ -656,6 +658,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
656
658
  }
657
659
  process.on("SIGINT", () => shutdown("SIGINT"))
658
660
  process.on("SIGTERM", () => shutdown("SIGTERM"))
661
+ // dispatch から呼べるように shutdown を ctx に載せる (agent.shutdown 用)。
662
+ ctx.requestShutdown = shutdown
659
663
 
660
664
  // 0.6.2 fix: 例外で silent 終了しないよう最後のセーフティネット。
661
665
  // Node 24 で unhandledRejection は default で process を kill する仕様のため、
@@ -1136,6 +1140,21 @@ async function dispatch(msg, ctx) {
1136
1140
  case "error":
1137
1141
  ctx.logger.warn({ msg }, "hub error")
1138
1142
  return
1143
+ case "agent.shutdown": {
1144
+ // Cockpit の「エージェント削除 (ゴミ箱)」由来。この端末をエージェントとして
1145
+ // 撤去する: サービス停止・無効化・autostart 撤去のみ。ユーザーデータ
1146
+ // (~/.hub / ~/.claude / プロジェクト) には触れない。
1147
+ ctx.logger.info({ msg }, "agent.shutdown received — self-uninstalling")
1148
+ try {
1149
+ // cgroup の外で動く detached プロセスにクリーンアップを委譲してから exit。
1150
+ requestSelfUninstall()
1151
+ } catch (err) {
1152
+ ctx.logger.warn({ err: err?.message }, "requestSelfUninstall failed")
1153
+ }
1154
+ // 本体は即停止 (detached cleanup が disable/stop を完走させる)。
1155
+ await ctx.requestShutdown?.("agent.shutdown")
1156
+ return
1157
+ }
1139
1158
  case "pty.attach": {
1140
1159
  const stream_id = msg.stream_id
1141
1160
  try {
@@ -19,6 +19,9 @@ import { readConfig } from "./config.mjs"
19
19
 
20
20
  const PLIST_LABEL = "co.cocorograph.hub-agent"
21
21
  const SYSTEMD_UNIT_NAME = "hub-agent.service"
22
+ // Windows(WSL) のログオン keep-alive タスク名 (install.ps1 の $TASK_NAME と一致)。
23
+ // WSL からは Windows 連携 (interop) で schtasks.exe を叩いて削除できる。
24
+ const WSL_BOOT_TASK_NAME = "HubAgentWSLBoot"
22
25
 
23
26
  function repoTemplatesDir() {
24
27
  // src/service-install.mjs → ../templates
@@ -323,7 +326,14 @@ export async function installService({ bin } = {}) {
323
326
  // 無い間 (= WSL2 のログオンタスク経由の boot や headless) に常駐しない。
324
327
  const linger = enableLinger()
325
328
  run("systemctl", ["--user", "daemon-reload"])
326
- run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT_NAME])
329
+ run("systemctl", ["--user", "enable", SYSTEMD_UNIT_NAME])
330
+ // `enable --now` は既に起動中のサービスを再起動しない (--now は停止中なら start、
331
+ // 起動中なら no-op)。distro を起動し続ける keep-alive と組み合わさると、再 enroll で
332
+ // agent.json のトークンを書き換えても古いプロセスが旧トークンのまま生き続け、
333
+ // (同一ホスト重複解消で旧 agent 行が消えるため) WS 403 でオフラインになる。
334
+ // restart は停止中なら起動・起動中なら再起動するので、install-service のたびに
335
+ // 必ず最新の agent.json でプロセスを入れ替える。
336
+ run("systemctl", ["--user", "restart", SYSTEMD_UNIT_NAME])
327
337
  return {
328
338
  platform: "linux",
329
339
  path: dest,
@@ -409,6 +419,75 @@ export async function uninstallService() {
409
419
  throw new Error(`unsupported platform: ${process.platform}`)
410
420
  }
411
421
 
422
+ /**
423
+ * Cockpit の「エージェント削除 (ゴミ箱)」を受けて、この端末を **エージェントとして
424
+ * 撤去** する。サービス停止・無効化・autostart 撤去のみを行い、ユーザーデータ
425
+ * (`~/.hub` / `~/.claude` / プロジェクト) には一切触れない。
426
+ *
427
+ * 重要: hub-agent.service は `Restart=always`。自プロセスから `systemctl --user
428
+ * stop` すると自分の cgroup ごと殺され、後続のクリーンアップが完走しない。そこで
429
+ * **サービスの cgroup の外** で動く detached プロセスにクリーンアップを委譲し、
430
+ * 本体はすぐ exit する (呼び出し側が exit する)。
431
+ *
432
+ * 撤去内容:
433
+ * - systemd user unit: disable --now + unit ファイル削除 + daemon-reload
434
+ * - linger: loginctl disable-linger
435
+ * - Windows(WSL): schtasks.exe で keep-alive タスク削除 (interop。非WSLでは no-op)
436
+ *
437
+ * @param {{spawnSync?: typeof spawnSync}} [opts] テスト用に spawn を注入可能。
438
+ * @returns {boolean} クリーンアップ用 detached プロセスの起動に成功したら true。
439
+ */
440
+ export function requestSelfUninstall(opts = {}) {
441
+ const spawn = opts.spawnSync ?? spawnSync
442
+
443
+ if (process.platform === "linux") {
444
+ const user = currentUsername()
445
+ const unit = linuxUnitPath()
446
+ // 1 秒待ってから (本体 exit を待つ) 撤去。各手順は失敗しても続行 (|| true)。
447
+ const script = [
448
+ "sleep 1",
449
+ `systemctl --user disable --now ${SYSTEMD_UNIT_NAME} 2>/dev/null || true`,
450
+ `rm -f '${unit}' 2>/dev/null || true`,
451
+ "systemctl --user daemon-reload 2>/dev/null || true",
452
+ `loginctl disable-linger ${user} 2>/dev/null || true`,
453
+ // WSL のみ有効 (Windows interop)。schtasks.exe が無い環境では黙って失敗。
454
+ `schtasks.exe /delete /tn ${WSL_BOOT_TASK_NAME} /f >/dev/null 2>&1 || true`,
455
+ ].join("; ")
456
+
457
+ // systemd-run --user --scope で別 scope(=別 cgroup) に逃がす。これにより
458
+ // hub-agent.service を stop しても掃除プロセスは生き残って完走する。
459
+ const r = spawn(
460
+ "systemd-run",
461
+ ["--user", "--scope", "--collect", "--quiet", "bash", "-lc", script],
462
+ { stdio: "ignore", detached: true },
463
+ )
464
+ if (!r.error && r.status === 0) return true
465
+ // systemd-run が無い/失敗時は setsid で cgroup から外して代替。
466
+ const r2 = spawn("setsid", ["bash", "-lc", script], {
467
+ stdio: "ignore",
468
+ detached: true,
469
+ })
470
+ return !r2.error
471
+ }
472
+
473
+ if (process.platform === "darwin") {
474
+ const uid = ensureUnixUid()
475
+ const dest = macPlistPath()
476
+ // launchctl bootout は job の全プロセスを殺すため、setsid で job から切り離した
477
+ // detached プロセスに委譲する。
478
+ const script =
479
+ `sleep 1; launchctl bootout gui/${uid} '${dest}' 2>/dev/null || true; ` +
480
+ `rm -f '${dest}' 2>/dev/null || true`
481
+ const r = spawn("bash", ["-lc", `setsid bash -lc "${script}" >/dev/null 2>&1 &`], {
482
+ stdio: "ignore",
483
+ detached: true,
484
+ })
485
+ return !r.error
486
+ }
487
+
488
+ return false
489
+ }
490
+
412
491
  export const _internal = {
413
492
  expandTemplate,
414
493
  detectHubAgentBin,
@@ -420,4 +499,5 @@ export const _internal = {
420
499
  bootstrapWithRetry,
421
500
  currentUsername,
422
501
  enableLinger,
502
+ requestSelfUninstall,
423
503
  }
package/src/ws-client.mjs CHANGED
@@ -84,6 +84,7 @@ export class WsClient extends EventEmitter {
84
84
  this.bundleWatcher = null
85
85
  this.bundleWatchDebounceTimer = null
86
86
  this.hostname = opts.hostname || os.hostname()
87
+ this.platform = opts.platform || "unknown"
87
88
  this.ws = null
88
89
  this.heartbeatTimer = null
89
90
  this.reconnectTimer = null
@@ -190,6 +191,7 @@ export class WsClient extends EventEmitter {
190
191
  hostname: this.hostname,
191
192
  version: this.version,
192
193
  bundle_version: this.bundleVersion,
194
+ platform: this.platform,
193
195
  })
194
196
  // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
195
197
  // (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、