@cocorograph/hub-agent 0.6.18 → 0.6.20

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.18",
3
+ "version": "0.6.20",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -389,12 +389,17 @@ class ClaudeStreamSession {
389
389
  // 走行中ターンは完走を待ち TTL 後に再チェック (走行中は絶対に撤去しない)
390
390
  if (this._busy) {
391
391
  this._idleTimer = setTimeout(tick, ttlMs)
392
+ // idle TTL は最大 7 日。タイマー単独でイベントループを生かさない (unref)。
393
+ // 本番デーモンは WS 接続等で常駐するので reap は問題なく発火し、テスト/CLI
394
+ // など他に handle が無いプロセスは即座に終了できる。
395
+ this._idleTimer.unref?.()
392
396
  return
393
397
  }
394
398
  this._idleTimer = null
395
399
  onTimeout?.()
396
400
  }
397
401
  this._idleTimer = setTimeout(tick, ttlMs)
402
+ this._idleTimer.unref?.()
398
403
  }
399
404
 
400
405
  /**
@@ -15,6 +15,8 @@ import path from "node:path"
15
15
  import { spawnSync } from "node:child_process"
16
16
  import { fileURLToPath } from "node:url"
17
17
 
18
+ import { readConfig } from "./config.mjs"
19
+
18
20
  const PLIST_LABEL = "co.cocorograph.hub-agent"
19
21
  const SYSTEMD_UNIT_NAME = "hub-agent.service"
20
22
 
@@ -50,7 +52,7 @@ function escapeXmlText(s) {
50
52
  .replaceAll(">", ">")
51
53
  }
52
54
 
53
- function expandTemplate(text, hubAgentBin) {
55
+ function expandTemplate(text, hubAgentBin, opts = {}) {
54
56
  // hub-agent デーモンの runtime 環境に必須な追加 env を、各プラットフォームの
55
57
  // 起動ファイル形式 (plist / systemd) に合わせて差し込むためのプレースホルダ展開。
56
58
  //
@@ -72,12 +74,43 @@ function expandTemplate(text, hubAgentBin) {
72
74
  // systemd 側: Environment=KEY=VALUE の 1 行。末尾 \n でテンプレ "__...__\n" を吸収。
73
75
  systemdLine = `Environment=NODE_EXTRA_CA_CERTS=${nodeExtraCa}\n`
74
76
  }
77
+
78
+ // 多端末共有チャット (HUB_AGENT_CHAT_SHARED)。NODE_EXTRA_CA_CERTS と同じ仕組みで
79
+ // plist / systemd の EnvironmentVariables に焼き込む。値ソースは (a) installService
80
+ // が agent.json.chat_shared を読んで渡す opts.chatShared、または (b) install-service
81
+ // 実行時の process.env.HUB_AGENT_CHAT_SHARED。これにより agent 更新で install-service
82
+ // が plist を再生成しても、agent.json に chat_shared=true があればフラグが維持される
83
+ // (旧実装は手動 plist 追記が再生成で消える事故があった。2026-05-30)。
84
+ const chatShared =
85
+ opts.chatShared === true || process.env.HUB_AGENT_CHAT_SHARED === "1"
86
+ let chatPlistEntry = ""
87
+ let chatSystemdLine = ""
88
+ if (chatShared) {
89
+ chatPlistEntry =
90
+ ` <key>HUB_AGENT_CHAT_SHARED</key>\n` +
91
+ ` <string>1</string>\n`
92
+ chatSystemdLine = `Environment=HUB_AGENT_CHAT_SHARED=1\n`
93
+ }
94
+
75
95
  return text
76
96
  .replaceAll("__HUB_AGENT_BIN__", hubAgentBin)
77
97
  .replaceAll("__HOME__", os.homedir())
78
98
  .replaceAll("__PATH__", process.env.PATH || "/usr/local/bin:/usr/bin:/bin")
79
99
  .replaceAll("__NODE_EXTRA_CA_CERTS_PLIST_ENTRY__", plistEntry)
80
100
  .replaceAll("__NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__", systemdLine)
101
+ .replaceAll("__HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__", chatPlistEntry)
102
+ .replaceAll("__HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__", chatSystemdLine)
103
+ }
104
+
105
+ /** agent.json から多端末共有フラグ (chat_shared) を読む。読めなければ false。
106
+ * install-service 時に plist/systemd へ焼き込む値ソースに使う (env 併用は expandTemplate 側)。 */
107
+ async function readChatSharedFromConfig() {
108
+ try {
109
+ const config = await readConfig()
110
+ return config?.chat_shared === true
111
+ } catch {
112
+ return false
113
+ }
81
114
  }
82
115
 
83
116
  async function ensureDir(p) {
@@ -95,6 +128,67 @@ function run(cmd, args, opts = {}) {
95
128
  }
96
129
  }
97
130
 
131
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
132
+
133
+ /**
134
+ * macOS の `launchctl bootout` は非同期で、graceful shutdown を持つ hub-agent は
135
+ * 実際に launchd の登録から消えるまでに時間がかかる。直後に `bootstrap` を叩くと
136
+ * 旧サービスがまだ抜け切っておらず `Bootstrap failed: 5: Input/output error` 等で
137
+ * 失敗し、plist はディスクにあるのにロードされない (= KeepAlive 不発 → offline)
138
+ * 状態に陥る。本関数は対象 service が launchd の登録から消えるまで polling して待つ。
139
+ * (2026-05-30: install-service の bootout→bootstrap レースで offline 化する事故の修正)
140
+ *
141
+ * @param {number} uid
142
+ * @param {{ timeoutMs?: number, intervalMs?: number }} [opts]
143
+ * @returns {Promise<boolean>} 消えたら true / timeout なら false
144
+ */
145
+ async function waitUntilBootedOut(uid, opts = {}) {
146
+ const timeoutMs = opts.timeoutMs ?? 5000
147
+ const intervalMs = opts.intervalMs ?? 200
148
+ const spawn = opts.spawn ?? spawnSync
149
+ const sleepFn = opts.sleep ?? sleep
150
+ const now = opts.now ?? (() => Date.now())
151
+ const deadline = now() + timeoutMs
152
+ while (now() < deadline) {
153
+ const r = spawn("launchctl", ["print", `gui/${uid}/${PLIST_LABEL}`], {
154
+ stdio: "ignore",
155
+ })
156
+ // 非 0 = 登録に存在しない = bootout 完了
157
+ if (r.status !== 0) return true
158
+ await sleepFn(intervalMs)
159
+ }
160
+ return false
161
+ }
162
+
163
+ /**
164
+ * bootout 後の bootstrap を、過渡的失敗 (旧サービスの抜け残り等) に対して
165
+ * リトライする。`run` と違い最終的に失敗したときだけ throw する。
166
+ *
167
+ * @param {number} uid
168
+ * @param {string} dest plist パス
169
+ * @param {{ attempts?: number, intervalMs?: number }} [opts]
170
+ */
171
+ async function bootstrapWithRetry(uid, dest, opts = {}) {
172
+ const attempts = opts.attempts ?? 4
173
+ const intervalMs = opts.intervalMs ?? 400
174
+ const spawn = opts.spawn ?? spawnSync
175
+ const sleepFn = opts.sleep ?? sleep
176
+ let lastStatus = null
177
+ for (let i = 0; i < attempts; i++) {
178
+ const r = spawn("launchctl", ["bootstrap", `gui/${uid}`, dest], {
179
+ stdio: "inherit",
180
+ })
181
+ if (r.status === 0) return
182
+ // 既にロード済み (status 37 / "service already loaded") は成功とみなす
183
+ if (r.status === 37) return
184
+ lastStatus = r.status
185
+ await sleepFn(intervalMs)
186
+ }
187
+ throw new Error(
188
+ `launchctl bootstrap gui/${uid} ${dest} failed after ${attempts} attempts (last exit ${lastStatus})`
189
+ )
190
+ }
191
+
98
192
  /**
99
193
  * `which hub-agent` の結果を、launchd / systemd から長期間 exec 可能な
100
194
  * 安定パスに正規化する。pure 関数。`opts` で fs を差し替えてテスト可能。
@@ -156,24 +250,34 @@ export async function installService({ bin } = {}) {
156
250
  const hubAgentBin = bin || detectHubAgentBin()
157
251
  await ensureLogFile()
158
252
 
253
+ // agent.json の永続フラグを読み、テンプレ展開に渡す (再生成でも維持されるように)。
254
+ const chatShared = await readChatSharedFromConfig()
255
+
159
256
  if (process.platform === "darwin") {
160
257
  const tpl = await readTemplate("co.cocorograph.hub-agent.plist")
161
- const expanded = expandTemplate(tpl, hubAgentBin)
258
+ const expanded = expandTemplate(tpl, hubAgentBin, { chatShared })
162
259
  const dest = macPlistPath()
163
260
  await ensureDir(path.dirname(dest))
164
261
  await fs.writeFile(dest, expanded, { mode: 0o644 })
165
262
 
166
263
  const uid = ensureUnixUid()
167
- // 既存ロード解除 → bootstrap → kickstart
264
+ // 既存ロード解除 → (登録が消えるまで待機) → bootstrap (リトライ) → kickstart
265
+ // bootout は非同期なので待たずに bootstrap するとレースで失敗し offline 化する
266
+ // (2026-05-30 の事故)。waitUntilBootedOut で確実に抜けてから bootstrap する。
168
267
  spawnSync("launchctl", ["bootout", `gui/${uid}`, dest], { stdio: "ignore" })
169
- run("launchctl", ["bootstrap", `gui/${uid}`, dest])
170
- run("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`])
268
+ await waitUntilBootedOut(uid)
269
+ await bootstrapWithRetry(uid, dest)
270
+ // kickstart は「既に走っている daemon を再起動」する用途。bootstrap + RunAtLoad で
271
+ // 既に起動済みのことがあり、その場合 kickstart 失敗は致命的でないので throw しない。
272
+ spawnSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
273
+ stdio: "ignore",
274
+ })
171
275
  return { platform: "darwin", path: dest, label: PLIST_LABEL, bin: hubAgentBin }
172
276
  }
173
277
 
174
278
  if (process.platform === "linux") {
175
279
  const tpl = await readTemplate("hub-agent.service")
176
- const expanded = expandTemplate(tpl, hubAgentBin)
280
+ const expanded = expandTemplate(tpl, hubAgentBin, { chatShared })
177
281
  const dest = linuxUnitPath()
178
282
  await ensureDir(path.dirname(dest))
179
283
  await fs.writeFile(dest, expanded, { mode: 0o644 })
@@ -264,4 +368,6 @@ export const _internal = {
264
368
  macPlistPath,
265
369
  linuxUnitPath,
266
370
  repoTemplatesDir,
371
+ waitUntilBootedOut,
372
+ bootstrapWithRetry,
267
373
  }
@@ -52,7 +52,7 @@
52
52
  <string>__PATH__</string>
53
53
  <key>HOME</key>
54
54
  <string>__HOME__</string>
55
- __NODE_EXTRA_CA_CERTS_PLIST_ENTRY__ </dict>
55
+ __NODE_EXTRA_CA_CERTS_PLIST_ENTRY____HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__ </dict>
56
56
 
57
57
  <!-- KeepAlive で過剰再起動した時に 10 秒スロットルする -->
58
58
  <key>ThrottleInterval</key>
@@ -12,7 +12,7 @@ Type=simple
12
12
  # 行に、未 set なら空文字列に」展開する。
13
13
  Environment=PATH=__PATH__
14
14
  Environment=HOME=__HOME__
15
- __NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__WorkingDirectory=__HOME__
15
+ __NODE_EXTRA_CA_CERTS_SYSTEMD_LINE____HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__WorkingDirectory=__HOME__
16
16
  ExecStart=__HUB_AGENT_BIN__ start
17
17
  Restart=always
18
18
  RestartSec=10