@cocorograph/hub-agent 0.6.11 → 0.6.12

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.11",
3
+ "version": "0.6.12",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -24,6 +24,12 @@ import { randomUUID } from "node:crypto"
24
24
  import { jsonlPath } from "./claude-history.mjs"
25
25
  import { watchSessionFile } from "./claude-history-watch.mjs"
26
26
 
27
+ /** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
28
+ * かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
29
+ * 設定 (放置セッションのメモリ蓄積は idle 時のみで小さい)。走行中ターンは TTL を過ぎても
30
+ * 完走するまで絶対に撤去しない。明示終了 / 新規セッションでは即時撤去される。 */
31
+ const IDLE_DETACH_TTL_MS = 7 * 24 * 60 * 60 * 1000
32
+
27
33
  /** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
28
34
  function extractPromptText(message) {
29
35
  if (typeof message === "string") return message
@@ -78,6 +84,11 @@ class ClaudeStreamSession {
78
84
  /** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
79
85
  this.sessionId = resumeSessionId || null
80
86
 
87
+ /** browser 切断中フラグ。true でも query は中断せず継続する (絶対に落とさない)。 */
88
+ this._detached = false
89
+ /** detached のまま放置されたセッションを撤去する idle タイマー。再アタッチでキャンセル。 */
90
+ this._idleTimer = null
91
+
81
92
  /** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
82
93
  this._permissionResolvers = new Map()
83
94
  /** 現在ターン実行中か (多重 query 防止) */
@@ -162,6 +173,41 @@ class ClaudeStreamSession {
162
173
  return true
163
174
  }
164
175
 
176
+ /** 再アタッチ: 走行中(または生存中)セッションに新しい stream_id を紐付け直し、
177
+ * idle 撤去タイマーを止める。以降のターンイベントはこの stream_id 経由で新しい
178
+ * browser 接続へライブに流れる (= 通常の生成中表示と同じ)。再アタッチ前の確定分は
179
+ * browser 側の jsonl hydrate (history.request) で復元するため、ここでは replay しない。 */
180
+ reattach(stream_id) {
181
+ this.stream_id = stream_id
182
+ this._detached = false
183
+ if (this._idleTimer) {
184
+ clearTimeout(this._idleTimer)
185
+ this._idleTimer = null
186
+ }
187
+ }
188
+
189
+ /** soft detach: browser 切断時にターンを中断せずセッションを生かしたまま detached に
190
+ * する。idle TTL 経過後に初めて onTimeout (= reap) を呼ぶ。再アタッチでキャンセル。 */
191
+ softDetach(ttlMs, onTimeout) {
192
+ this._detached = true
193
+ if (this._idleTimer) clearTimeout(this._idleTimer)
194
+ const tick = () => {
195
+ // 再アタッチ済みなら撤去しない (reattach が _detached=false にする)
196
+ if (!this._detached) {
197
+ this._idleTimer = null
198
+ return
199
+ }
200
+ // 走行中ターンは完走を待ち TTL 後に再チェック (走行中は絶対に撤去しない)
201
+ if (this._busy) {
202
+ this._idleTimer = setTimeout(tick, ttlMs)
203
+ return
204
+ }
205
+ this._idleTimer = null
206
+ onTimeout?.()
207
+ }
208
+ this._idleTimer = setTimeout(tick, ttlMs)
209
+ }
210
+
165
211
  /**
166
212
  * ユーザーメッセージ 1 件を 1 query() として実行する。
167
213
  * 既存ターン実行中は無視 (UI 側で送信を抑止する想定だが念のため)。
@@ -303,6 +349,10 @@ class ClaudeStreamSession {
303
349
  close() {
304
350
  this._closed = true
305
351
  this.abortTurn()
352
+ if (this._idleTimer) {
353
+ clearTimeout(this._idleTimer)
354
+ this._idleTimer = null
355
+ }
306
356
  if (this._watcher) {
307
357
  try {
308
358
  this._watcher.stop()
@@ -338,6 +388,9 @@ export class ClaudeStreamBridge extends EventEmitter {
338
388
  this.logger = logger
339
389
  /** @type {Map<string, ClaudeStreamSession>} */
340
390
  this.sessions = new Map()
391
+ /** session_id → 生存セッション。再接続 (再アタッチ) で同一セッションを引く索引。
392
+ * browser が切れてもここに残し、走行中ターンの継続 + 再接続でのライブ追従を可能にする。 */
393
+ this._liveBySession = new Map()
341
394
  }
342
395
 
343
396
  /**
@@ -367,6 +420,23 @@ export class ClaudeStreamBridge extends EventEmitter {
367
420
  if (this.sessions.has(stream_id)) {
368
421
  throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
369
422
  }
423
+ // 常駐化: resume_session_id が示すセッションが (走行中 / idle 問わず) まだ生存していれば、
424
+ // 新規 query を起動せずそのセッションへ再アタッチする。これにより端末スリープ/再接続でも
425
+ // ターンは落ちず、以降のイベントは新しい stream_id 経由でライブに流れる
426
+ // (= 通常の生成中表示と同じ)。再接続前の確定分は browser の jsonl hydrate で復元する。
427
+ if (resumeSessionId) {
428
+ const live = this._liveBySession.get(resumeSessionId)
429
+ if (live && !live._closed) {
430
+ this.sessions.delete(live.stream_id)
431
+ live.reattach(stream_id)
432
+ this.sessions.set(stream_id, live)
433
+ this.logger?.info(
434
+ { stream_id, resume: resumeSessionId, busy: live._busy },
435
+ "claude stream reattached to live session",
436
+ )
437
+ return { stream_id, resuming: true, reattached: true }
438
+ }
439
+ }
370
440
  const session = new ClaudeStreamSession({
371
441
  stream_id,
372
442
  cwd: cwd || process.env.HOME || process.cwd(),
@@ -379,21 +449,32 @@ export class ClaudeStreamBridge extends EventEmitter {
379
449
  sdk: this.sdk,
380
450
  logger: this.logger,
381
451
  onEvent: (event) => {
382
- this.emit("event", { stream_id, session_id: session.sessionId, event })
452
+ // stream_id は再アタッチで変わるため session.stream_id (最新) を使う。
453
+ this.emit("event", { stream_id: session.stream_id, session_id: session.sessionId, event })
454
+ // session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
455
+ if (session.sessionId) this._liveBySession.set(session.sessionId, session)
383
456
  },
384
457
  onPermission: ({ tool_name, input, request_id }) => {
385
- this.emit("permission", { stream_id, request_id, tool_name, input })
458
+ this.emit("permission", {
459
+ stream_id: session.stream_id,
460
+ request_id,
461
+ tool_name,
462
+ input,
463
+ })
386
464
  },
387
465
  onError: (err) => {
388
- this.emit("error", { stream_id, error: err?.message || String(err) })
466
+ this.emit("error", { stream_id: session.stream_id, error: err?.message || String(err) })
389
467
  },
390
468
  onReap: () => {
391
- // ターン完走後の遅延クローズ。Map から撤去し exit を emit する。
392
- if (this.sessions.get(stream_id) === session) {
393
- this.sessions.delete(stream_id)
469
+ // ターン完走後の遅延クローズ。Map / 索引から撤去し exit を emit する。
470
+ if (this.sessions.get(session.stream_id) === session) {
471
+ this.sessions.delete(session.stream_id)
472
+ }
473
+ if (session.sessionId && this._liveBySession.get(session.sessionId) === session) {
474
+ this._liveBySession.delete(session.sessionId)
394
475
  }
395
476
  this.emit("exit", {
396
- stream_id,
477
+ stream_id: session.stream_id,
397
478
  code: 0,
398
479
  reason: "detached-after-turn",
399
480
  session_id: session.sessionId,
@@ -457,11 +538,22 @@ export class ClaudeStreamBridge extends EventEmitter {
457
538
  detach({ stream_id }) {
458
539
  const s = this.sessions.get(stream_id)
459
540
  if (!s) return false
460
- const reaped = s.gracefulClose()
461
- if (reaped) {
462
- this.sessions.delete(stream_id)
463
- this.emit("exit", { stream_id, code: 0, reason: "detached", session_id: s.sessionId })
464
- }
541
+ // 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
542
+ // idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
543
+ // されればキャンセルされる。明示的に止めたい場合は interrupt()/明示終了を使う。
544
+ s.softDetach(IDLE_DETACH_TTL_MS, () => {
545
+ if (this.sessions.get(s.stream_id) === s) this.sessions.delete(s.stream_id)
546
+ if (s.sessionId && this._liveBySession.get(s.sessionId) === s) {
547
+ this._liveBySession.delete(s.sessionId)
548
+ }
549
+ s.close()
550
+ this.emit("exit", {
551
+ stream_id: s.stream_id,
552
+ code: 0,
553
+ reason: "idle-reaped",
554
+ session_id: s.sessionId,
555
+ })
556
+ })
465
557
  return true
466
558
  }
467
559
 
@@ -474,6 +566,8 @@ export class ClaudeStreamBridge extends EventEmitter {
474
566
  this.sessions.delete(stream_id)
475
567
  this.emit("exit", { stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
476
568
  }
569
+ // 常駐化: 再アタッチ索引も全消去 (プロセス終了時の後始末)。
570
+ this._liveBySession.clear()
477
571
  }
478
572
 
479
573
  /** 現在 attach 中の stream_id 一覧 (debug 用)。 */