@cocorograph/hub-agent 0.6.12 → 0.6.13

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.12",
3
+ "version": "0.6.13",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -30,6 +30,52 @@ import { watchSessionFile } from "./claude-history-watch.mjs"
30
30
  * 完走するまで絶対に撤去しない。明示終了 / 新規セッションでは即時撤去される。 */
31
31
  const IDLE_DETACH_TTL_MS = 7 * 24 * 60 * 60 * 1000
32
32
 
33
+ /** 改修2: チャット常駐query化のフラグ。env HUB_AGENT_CHAT_RESIDENT="0" で無効化し
34
+ * 従来の「1メッセージ=1query」へ即ロールバックできる。デフォルト有効。 */
35
+ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
36
+
37
+ /** 文字列を SDK streaming input の SDKUserMessage に包む。 */
38
+ function toSDKUserMessage(text) {
39
+ return { type: "user", message: { role: "user", content: text } }
40
+ }
41
+
42
+ /** 常駐 query の streaming input キュー。push された SDKUserMessage を AsyncIterable として
43
+ * query() に供給する。push が無ければ次を待ち (query は終了しない)、close で generator を
44
+ * 終わらせて query を完走させる。これにより 1 セッション = 1 query を実現し、system/init を
45
+ * 最初の 1 回だけにする (毎ターン init = 文脈/キャッシュ温め直しの解消)。 */
46
+ class InputQueue {
47
+ constructor() {
48
+ this._q = []
49
+ this._wake = null
50
+ this._closed = false
51
+ }
52
+ push(item) {
53
+ if (this._closed) return
54
+ this._q.push(item)
55
+ this._flush()
56
+ }
57
+ close() {
58
+ this._closed = true
59
+ this._flush()
60
+ }
61
+ _flush() {
62
+ if (this._wake) {
63
+ const w = this._wake
64
+ this._wake = null
65
+ w()
66
+ }
67
+ }
68
+ async *[Symbol.asyncIterator]() {
69
+ while (true) {
70
+ while (this._q.length) yield this._q.shift()
71
+ if (this._closed) return
72
+ await new Promise((r) => {
73
+ this._wake = r
74
+ })
75
+ }
76
+ }
77
+ }
78
+
33
79
  /** message (`{role, content}` or string) からプロンプト文字列を取り出す。 */
34
80
  function extractPromptText(message) {
35
81
  if (typeof message === "string") return message
@@ -57,6 +103,7 @@ class ClaudeStreamSession {
57
103
  maxTurns,
58
104
  maxThinkingTokens,
59
105
  resumeSessionId,
106
+ resident,
60
107
  sdk,
61
108
  logger,
62
109
  onEvent,
@@ -89,6 +136,20 @@ class ClaudeStreamSession {
89
136
  /** detached のまま放置されたセッションを撤去する idle タイマー。再アタッチでキャンセル。 */
90
137
  this._idleTimer = null
91
138
 
139
+ /** 改修2: 常駐query対象か。明示 resident 指定があれば優先 (テスト/将来の per-session 設定用)。
140
+ * 未指定なら resume なしの新規セッションのみ常駐 (既存 resume セッションは過去の
141
+ * 「Continue from where you left off」暴走を避けて従来 per-message を維持)。 */
142
+ this._residentEligible =
143
+ typeof resident === "boolean"
144
+ ? resident
145
+ : !resumeSessionId && CHAT_RESIDENT_ENABLED
146
+ /** 常駐query の input キュー (streaming input)。初回 input 時に生成・query 起動。 */
147
+ this._inputQueue = null
148
+ /** 起動済みの常駐 query ハンドル (interrupt() 用)。 */
149
+ this._residentQuery = null
150
+ /** 常駐 query を起動済みか (二重起動防止)。 */
151
+ this._residentStarted = false
152
+
92
153
  /** @type {Map<string, {resolve: (decision: object) => void}>} permission 応答待ち */
93
154
  this._permissionResolvers = new Map()
94
155
  /** 現在ターン実行中か (多重 query 防止) */
@@ -214,6 +275,28 @@ class ClaudeStreamSession {
214
275
  */
215
276
  async sendMessage(message) {
216
277
  if (this._closed) return
278
+ const prompt = extractPromptText(message)
279
+ if (!prompt) return
280
+
281
+ // 改修2: 常駐query対象セッションは streaming input キューへ積む (busy 中でも積み、
282
+ // SDK が順次処理する)。初回 input で常駐 query を 1 回だけ起動する。
283
+ if (this._residentEligible) {
284
+ if (!this._inputQueue) this._inputQueue = new InputQueue()
285
+ this._busy = true
286
+ this._inputQueue.push(toSDKUserMessage(prompt))
287
+ if (!this._residentStarted) {
288
+ this._residentStarted = true
289
+ this._runResidentQuery().catch((err) => {
290
+ this.logger?.error(
291
+ { stream_id: this.stream_id, err: err?.message },
292
+ "resident query threw",
293
+ )
294
+ })
295
+ }
296
+ return
297
+ }
298
+
299
+ // 従来 per-message: 1 メッセージ = 1 query (resume チェーン)。既存セッション (resume) 用。
217
300
  if (this._busy) {
218
301
  this.logger?.warn(
219
302
  { stream_id: this.stream_id },
@@ -221,8 +304,6 @@ class ClaudeStreamSession {
221
304
  )
222
305
  return
223
306
  }
224
- const prompt = extractPromptText(message)
225
- if (!prompt) return
226
307
 
227
308
  this._busy = true
228
309
  this._abortController = new AbortController()
@@ -320,8 +401,92 @@ class ClaudeStreamSession {
320
401
  }
321
402
  }
322
403
 
404
+ /** 改修2: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
405
+ * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
406
+ * result で _busy=false にするが query は継続し次 input を待つ。close (inputQueue.close) で
407
+ * generator が終わり query が完走する。resume は使わない (新規セッションのみが対象)。
408
+ * 常駐では query stream が唯一のイベント源のため watcher は張らない。 */
409
+ async _runResidentQuery() {
410
+ const options = {
411
+ cwd: this.cwd,
412
+ canUseTool: (toolName, input) => this._canUseTool(toolName, input),
413
+ includePartialMessages: true,
414
+ }
415
+ if (this.model) options.model = this.model
416
+ if (this.permissionMode) options.permissionMode = this.permissionMode
417
+ if (this.maxTurns != null) options.maxTurns = this.maxTurns
418
+ if (this.maxThinkingTokens != null) options.maxThinkingTokens = this.maxThinkingTokens
419
+ const denyPending = (reason) => {
420
+ for (const [, resolver] of this._permissionResolvers) {
421
+ try {
422
+ resolver.resolve({ behavior: "deny", message: reason })
423
+ } catch {
424
+ /* ignore */
425
+ }
426
+ }
427
+ this._permissionResolvers.clear()
428
+ }
429
+ try {
430
+ const q = this.sdk.query({ prompt: this._inputQueue, options })
431
+ this._residentQuery = q
432
+ for await (const msg of q) {
433
+ if (
434
+ msg?.type === "system" &&
435
+ msg?.subtype === "init" &&
436
+ typeof msg.session_id === "string"
437
+ ) {
438
+ this.sessionId = msg.session_id
439
+ }
440
+ if (msg?.type === "result") {
441
+ if (typeof msg.session_id === "string") this.sessionId = msg.session_id
442
+ // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
443
+ denyPending("turn ended")
444
+ this._busy = false
445
+ }
446
+ try {
447
+ this.onEvent?.(msg)
448
+ } catch (err) {
449
+ this.logger?.warn(
450
+ { err: err.message, stream_id: this.stream_id },
451
+ "onEvent callback threw",
452
+ )
453
+ }
454
+ }
455
+ } catch (err) {
456
+ if (!this._closed) {
457
+ try {
458
+ this.onError?.(err)
459
+ } catch {
460
+ /* ignore */
461
+ }
462
+ }
463
+ } finally {
464
+ this._residentQuery = null
465
+ this._busy = false
466
+ denyPending("closed")
467
+ // 常駐 query の終了 = セッション終了。detached 後にここへ来たら reap する。
468
+ if (this._reapAfterTurn && !this._closed) {
469
+ try {
470
+ this.close()
471
+ } finally {
472
+ this.onReap?.()
473
+ }
474
+ }
475
+ }
476
+ }
477
+
323
478
  /** 実行中ターンを中断する (次の input は引き続き受け付ける)。 */
324
479
  abortTurn() {
480
+ // 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
481
+ if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
482
+ try {
483
+ this._residentQuery.interrupt()
484
+ } catch {
485
+ /* ignore */
486
+ }
487
+ this._busy = false
488
+ return
489
+ }
325
490
  if (this._abortController) {
326
491
  try {
327
492
  this._abortController.abort()
@@ -348,6 +513,8 @@ class ClaudeStreamSession {
348
513
  /** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
349
514
  close() {
350
515
  this._closed = true
516
+ // 常駐 query は input キューを閉じて generator を終わらせ query を完走させる。
517
+ if (this._inputQueue) this._inputQueue.close()
351
518
  this.abortTurn()
352
519
  if (this._idleTimer) {
353
520
  clearTimeout(this._idleTimer)
@@ -415,6 +582,7 @@ export class ClaudeStreamBridge extends EventEmitter {
415
582
  maxTurns,
416
583
  maxThinkingTokens,
417
584
  resumeSessionId,
585
+ resident,
418
586
  }) {
419
587
  if (!stream_id) throw new TypeError("attach requires stream_id")
420
588
  if (this.sessions.has(stream_id)) {
@@ -446,6 +614,7 @@ export class ClaudeStreamBridge extends EventEmitter {
446
614
  maxThinkingTokens:
447
615
  typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
448
616
  resumeSessionId: resumeSessionId || null,
617
+ resident,
449
618
  sdk: this.sdk,
450
619
  logger: this.logger,
451
620
  onEvent: (event) => {