@cocorograph/hub-agent 0.5.28 → 0.5.29

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.5.28",
3
+ "version": "0.5.29",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -403,6 +403,45 @@ export async function maybeSyncClaudeSettings(msg, ctx) {
403
403
  }
404
404
  }
405
405
 
406
+ /**
407
+ * Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
408
+ * stream_id を能動同期する。
409
+ *
410
+ * - local にあって backend にない → ptyBridge.detach で即時 kill (孤児)
411
+ * - backend にあって local にない → backend に pty.error を通知 (browser に
412
+ * "pty 死んでます" を表示するため)
413
+ *
414
+ * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
415
+ */
416
+ export function handleStreamsSyncResponse(msg, ctx) {
417
+ const activeIds = new Set(
418
+ Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
419
+ )
420
+ const localIds = new Set(ctx.ptyBridge.list())
421
+ for (const sid of localIds) {
422
+ if (!activeIds.has(sid)) {
423
+ ctx.logger?.warn?.(
424
+ { stream_id: sid, request_id: msg?.request_id },
425
+ "Plan ε: orphan stream detected, detaching",
426
+ )
427
+ ctx.ptyBridge.detach({ stream_id: sid })
428
+ }
429
+ }
430
+ for (const sid of activeIds) {
431
+ if (!localIds.has(sid)) {
432
+ ctx.logger?.warn?.(
433
+ { stream_id: sid, request_id: msg?.request_id },
434
+ "Plan ε: stream missing on agent, notifying backend (pty.error)",
435
+ )
436
+ ctx.client.send({
437
+ type: "pty.error",
438
+ stream_id: sid,
439
+ error: "stream_not_found_on_agent",
440
+ })
441
+ }
442
+ }
443
+ }
444
+
406
445
  async function dispatch(msg, ctx) {
407
446
  const t = msg?.type || ""
408
447
  try {
@@ -456,6 +495,15 @@ async function dispatch(msg, ctx) {
456
495
  case "pty.detach":
457
496
  ctx.ptyBridge.detach({ stream_id: msg.stream_id })
458
497
  return
498
+ case "agent.streams.sync.response":
499
+ // Plan ε: backend ↔ agent の現存 stream_id 能動同期 (WS reconnect 直後に
500
+ // ws-client が agent.streams.sync.request を送って取得した結果)。
501
+ // ロジックは handleStreamsSyncResponse へ抽出 (テストから直接呼べる)。
502
+ // backend が旧 (= Plan ε 未対応) で response が一切来ないケースは何もしない。
503
+ // 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
504
+ // が飛ぶので無害。
505
+ handleStreamsSyncResponse(msg, ctx)
506
+ return
459
507
  case "tmux.exec": {
460
508
  const args = Array.isArray(msg.args) ? msg.args : []
461
509
  const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
package/src/ws-client.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
13
13
  */
14
+ import { randomUUID } from "node:crypto"
14
15
  import { EventEmitter } from "node:events"
15
16
  import fs from "node:fs"
16
17
  import os from "node:os"
@@ -68,22 +69,7 @@ export class WsClient extends EventEmitter {
68
69
  const ws = new WebSocket(wsUrl, { headers })
69
70
  this.ws = ws
70
71
 
71
- ws.on("open", () => {
72
- this.backoff = MIN_BACKOFF_MS
73
- this.logger?.info("ws open")
74
- // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
75
- this._refreshBundleVersion()
76
- this._sendJson({
77
- type: "hello",
78
- agent_id: this.config.agent_id,
79
- hostname: this.hostname,
80
- version: this.version,
81
- bundle_version: this.bundleVersion,
82
- })
83
- this._startHeartbeat()
84
- this._startBundleWatcher()
85
- this.emit("open")
86
- })
72
+ ws.on("open", () => this._onOpen())
87
73
 
88
74
  ws.on("message", (data) => {
89
75
  let msg
@@ -111,6 +97,43 @@ export class WsClient extends EventEmitter {
111
97
  })
112
98
  }
113
99
 
100
+ /**
101
+ * WS open 時の初期送信 + 後続タスク起動。
102
+ *
103
+ * - backoff をリセット
104
+ * - bundle version を最新化 → hello 送信
105
+ * - Plan ε: agent.streams.sync.request を送信 (毎 reconnect で能動同期)
106
+ * - heartbeat + bundle watcher を起動
107
+ *
108
+ * test からも直接呼べるように method として切り出している (`ws.on("open")`
109
+ * のクロージャだと spy しづらい)。
110
+ */
111
+ _onOpen() {
112
+ this.backoff = MIN_BACKOFF_MS
113
+ this.logger?.info("ws open")
114
+ // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
115
+ this._refreshBundleVersion()
116
+ this._sendJson({
117
+ type: "hello",
118
+ agent_id: this.config.agent_id,
119
+ hostname: this.hostname,
120
+ version: this.version,
121
+ bundle_version: this.bundleVersion,
122
+ })
123
+ // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
124
+ // (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、
125
+ // ptyBridge.list() と差分を取って kill する。request_id は uuid で
126
+ // 重複排除。backend が古い (= Plan ε 未対応) なら "unknown_type" error が
127
+ // 返るだけで動作には影響しない。
128
+ this._sendJson({
129
+ type: "agent.streams.sync.request",
130
+ request_id: randomUUID(),
131
+ })
132
+ this._startHeartbeat()
133
+ this._startBundleWatcher()
134
+ this.emit("open")
135
+ }
136
+
114
137
  /** メッセージを送る。未接続なら no-op (logger.warn)。 */
115
138
  send(obj) {
116
139
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {