@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
|
@@ -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
|
/**
|
package/src/service-install.mjs
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|