@cocorograph/hub-agent 0.8.56 → 0.8.57

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.8.56",
3
+ "version": "0.8.57",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -70,6 +70,11 @@ const STREAM_TICK_THROTTLE_MS = 3 * 1000
70
70
  // key → 最後に stream tick を記録した時刻 (スロットル判定用)。
71
71
  const _lastStreamTickAt = new Map()
72
72
 
73
+ // `${cliKind}\u0000${cwd}` → チャット入力を最後に受け付けた時刻。handler が dispatch 前に同期で記録する
74
+ // (bridge の応答を待つ間に turn/started 等で state loop が processing を観測しても、観測時刻ではなく
75
+ // この受付時刻で prompt_submit を書けるように)。tmux 走査より先に記録するので走査失敗にも影響されない。
76
+ const _promptAcceptedAtByKey = new Map()
77
+
73
78
  const VALID_STATUS = new Set(["processing", "waiting", "idle"])
74
79
 
75
80
  function _now() {
@@ -88,6 +93,19 @@ function _key(cwd, cliKind) {
88
93
  return `${normalizeCliKind(cliKind)}\u0000${cwd}`
89
94
  }
90
95
 
96
+ /** (cwd, cliKind) の最新 prompt 受付時刻を単調に記録する。 */
97
+ export function recordPromptAccepted(cwd, cliKind, at) {
98
+ const key = _key(cwd, cliKind)
99
+ if (!key) return
100
+ _promptAcceptedAtByKey.set(key, Math.max(_promptAcceptedAtByKey.get(key) ?? 0, at))
101
+ }
102
+
103
+ /** (cwd, cliKind) の最新 prompt 受付時刻を返す (無ければ undefined)。 */
104
+ export function getPromptAcceptedAt(cwd, cliKind) {
105
+ const key = _key(cwd, cliKind)
106
+ return key ? _promptAcceptedAtByKey.get(key) : undefined
107
+ }
108
+
91
109
  /**
92
110
  * チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
93
111
  * turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
@@ -239,12 +257,14 @@ export function clearChatSignal(cwd, cliKind) {
239
257
  if (!key) return
240
258
  _byKey.delete(key)
241
259
  _lastStreamTickAt.delete(key)
260
+ _promptAcceptedAtByKey.delete(key)
242
261
  }
243
262
 
244
263
  /** テスト用: ストアを空にする。 */
245
264
  export function _resetChatSignals() {
246
265
  _byKey.clear()
247
266
  _lastStreamTickAt.clear()
267
+ _promptAcceptedAtByKey.clear()
248
268
  }
249
269
 
250
270
  export { CHAT_SIGNAL_STALE_MS, STREAM_TICK_THROTTLE_MS }
@@ -3890,4 +3890,8 @@ export class ClaudeStreamBridge extends EventEmitter {
3890
3890
  const s = this.sessions.get(stream_id)
3891
3891
  return s ? s.sessionId : null
3892
3892
  }
3893
+
3894
+ getCwd(stream_id) {
3895
+ return this.sessions.get(stream_id)?.cwd ?? null
3896
+ }
3893
3897
  }
@@ -4760,4 +4760,10 @@ export class CodexStreamBridge extends EventEmitter {
4760
4760
  }
4761
4761
  this.sessions.clear()
4762
4762
  }
4763
+
4764
+ getCwd(stream_id) {
4765
+ const session = this.sessions.get(stream_id)
4766
+ if (!session || session.dead) return null
4767
+ return session.cwd ?? null
4768
+ }
4763
4769
  }
package/src/main.mjs CHANGED
@@ -139,8 +139,10 @@ import {
139
139
  import {
140
140
  clearChatSignal,
141
141
  getChatSignal,
142
+ getPromptAcceptedAt,
142
143
  recordChatActivity,
143
144
  recordChatStreamTick,
145
+ recordPromptAccepted,
144
146
  } from "./chat-signals.mjs"
145
147
  import { cliKindForSessionName, sessionNameFor } from "./session-names.mjs"
146
148
  import {
@@ -1686,18 +1688,31 @@ export function updateSawUserTurn(name, event) {
1686
1688
  * @param {number} at epoch ms
1687
1689
  * @returns {{ sortAffecting: boolean, sortAffectingAt: number|undefined, stale: boolean }}
1688
1690
  */
1689
- export function ingestSessionEventForSort(name, event, at) {
1691
+ export function ingestSessionEventForSort(name, event, at, { fresh = false } = {}) {
1690
1692
  const prevAt = lastEventAtByName.get(name) ?? 0
1691
1693
  // stale (この session で処理済みの at より古い) read は状態を汚さない。arm や status は
1692
1694
  // 呼び出し側で従来通り処理してよいが、sort へは一切効かせない。
1693
- if (typeof at === "number" && at < prevAt) {
1695
+ // `fresh`: in-process の直接 publish (チャット入力の受付) は out-of-order な file read ではなく
1696
+ // 「いま起きた」event なので、この stale 判定を通さない (受付〜tmux 走査の ms 窓に state loop の
1697
+ // stop が先に取り込まれても、人間の送信を落とさない)。sort 時刻の単調性は下の比較で保つ。
1698
+ if (!fresh && typeof at === "number" && at < prevAt) {
1694
1699
  return {
1695
1700
  sortAffecting: false,
1696
1701
  sortAffectingAt: lastSortAffectingAtByName.get(name),
1697
1702
  stale: true,
1698
1703
  }
1699
1704
  }
1700
- if (typeof at === "number") lastEventAtByName.set(name, at)
1705
+ // fresh な prompt_submit でも sort 時刻を前進させないもの (同一行への 2 送信の tmux 走査が逆順に
1706
+ // 完了した後着の古い方、同値の再通知) は、frontend へ sort_affecting=true で流さない。frontend は
1707
+ // 単調マージで弾くが、契約として「sort_affecting=true は sort 時刻が前進した event」に揃える。
1708
+ if (fresh && typeof at === "number" && at <= (lastSortAffectingAtByName.get(name) ?? 0)) {
1709
+ return {
1710
+ sortAffecting: false,
1711
+ sortAffectingAt: lastSortAffectingAtByName.get(name),
1712
+ stale: true,
1713
+ }
1714
+ }
1715
+ if (typeof at === "number") lastEventAtByName.set(name, Math.max(prevAt, at))
1701
1716
  updateSawUserTurn(name, event)
1702
1717
  const sortAffecting = computeSortAffecting(event, sawUserTurnByName.get(name) === true)
1703
1718
  // sort_affecting=true で前進したときだけ sort 時刻を更新し、sidecar に永続化する (major#1)。
@@ -1937,50 +1952,8 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1937
1952
  const text = await readFile(full, "utf-8")
1938
1953
  const data = JSON.parse(text)
1939
1954
  if (!data || typeof data.event !== "string") return
1940
- // ターン境界 (prompt_submit / stop) で readiness tracker を arm し、子プロセスが
1941
- // baseline へ復帰したら 'ready' を出す土台にする。'ready' 自身では arm しない
1942
- // (readiness loop の書込み → 再 arm の無限ループを避ける)。prompt_submit は次ターンの
1943
- // ready を解禁するため emitted をリセットする。
1944
- if (readinessTracker) {
1945
- if (data.event === "prompt_submit") {
1946
- readinessTracker.arm(sessionName, { newTurn: true })
1947
- } else if (data.event === "stop" || data.event === "idle_hint") {
1948
- // stop: ターン論理終了。idle_hint: Notification(idle_prompt) フックの裏取り。
1949
- // どちらも arm するだけで、実際の 'ready' は proc 内省 (子プロセス baseline 復帰)
1950
- // が確定させる。これにより Notification が Stop フック完了前に発火しても、
1951
- // proc が clear するまで誤フラッシュしない (idle_hint→即 ready にはしない)。
1952
- readinessTracker.arm(sessionName)
1953
- }
1954
- }
1955
- // sort_affecting 判定を状態機械へ委譲する: at 単調ガードで out-of-order な stale read を
1956
- // 弾き (major#2)、prompt_submit/session_start で状態遷移し、sort_affecting=true なら
1957
- // sort 時刻を更新する (major#1)。arm や status push は従来通り (stale でも status は最新化)。
1958
1955
  const at = typeof data.at === "number" ? data.at : Date.now()
1959
- const { sortAffecting } = ingestSessionEventForSort(sessionName, data.event, at)
1960
- // 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
1961
- // ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
1962
- if (STATE_TRACE) {
1963
- logger?.info(
1964
- {
1965
- session_name: sessionName,
1966
- event: data.event,
1967
- armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
1968
- sort_affecting: sortAffecting,
1969
- },
1970
- "session.event push",
1971
- )
1972
- }
1973
- // 直前に作った一覧 snapshot がこの event より古い場合、次の poll で last_event を
1974
- // 逆戻りさせない。build 中なら AsyncTtlCache が新世代へ合流し直す。
1975
- tmuxSessionsSnapshotCache.clear()
1976
- client.send({
1977
- type: "session.event",
1978
- session_name: sessionName,
1979
- event: data.event,
1980
- at,
1981
- // false=ライフサイクル/resume 落ち着き由来 → frontend は status のみ更新し並び順は動かさない。
1982
- sort_affecting: sortAffecting,
1983
- })
1956
+ await publishSessionEvent({ client, readinessTracker, logger }, sessionName, data.event, at)
1984
1957
  } catch {
1985
1958
  // 一時欠如 / parse 失敗等は無視 (次の変更で読み直す)
1986
1959
  }
@@ -2021,6 +1994,46 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
2021
1994
  }
2022
1995
  }
2023
1996
 
1997
+ /** session-event をプロセス内で取り込み、並び順を更新して WS push する。 */
1998
+ export async function publishSessionEvent(
1999
+ { client, readinessTracker, logger },
2000
+ sessionName,
2001
+ event,
2002
+ at,
2003
+ { fresh = false, arm = !fresh } = {},
2004
+ ) {
2005
+ // readiness (proc 内省の ready 判定) を動かすのはファイル監視経路だけ。直接 publish (fresh) は
2006
+ // 「人間が送った」事実を並び順へ流すだけで、ターンの開始を意味しない (bridge が拒否すれば
2007
+ // ターンは始まらないし、走査が逆順に完了した後着の古い方は既に終わったターン)。ここで arm すると
2008
+ // 次の stop が来ない限り busy が残る。ターン境界の arm は従来どおり state loop / bundle hook が
2009
+ // 書く event ファイル経由で行う。
2010
+ const { sortAffecting } = ingestSessionEventForSort(sessionName, event, at, { fresh })
2011
+ if (arm) {
2012
+ if (event === "prompt_submit") {
2013
+ readinessTracker?.arm(sessionName, { newTurn: true })
2014
+ } else if (event === "stop" || event === "idle_hint") {
2015
+ readinessTracker?.arm(sessionName)
2016
+ }
2017
+ }
2018
+ if (STATE_TRACE) {
2019
+ logger?.info?.({
2020
+ session_name: sessionName,
2021
+ event,
2022
+ armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
2023
+ sort_affecting: sortAffecting,
2024
+ }, "session.event push")
2025
+ }
2026
+ tmuxSessionsSnapshotCache.clear()
2027
+ client.send({
2028
+ type: "session.event",
2029
+ session_name: sessionName,
2030
+ event,
2031
+ at,
2032
+ sort_affecting: sortAffecting,
2033
+ })
2034
+ return { sortAffecting }
2035
+ }
2036
+
2024
2037
 
2025
2038
  /**
2026
2039
  * arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
@@ -2414,6 +2427,71 @@ export async function pushImmediateChatState({
2414
2427
  }
2415
2428
  }
2416
2429
 
2430
+ /**
2431
+ * チャット入力の受付時刻を、対応する tmux 行の prompt_submit として **in-process で** publish する。
2432
+ *
2433
+ * state loop の 5 秒 tick だけでは、5 秒未満で完了するターンや、終了と次の開始の間に tick が
2434
+ * 入らない連続ターンを取りこぼし、観測できても送信時刻ではなく assistant event の時刻になる。
2435
+ * そこで handler が入力を受け付けた瞬間 (dispatch 前) に、cwd と CLI 種別が一致する tmux 行へ
2436
+ * publishSessionEvent(prompt_submit, 受付時刻, fresh) を流す。
2437
+ *
2438
+ * ファイルは書かない。並び順の正本は ingestSessionEventForSort の sidecar (poll の
2439
+ * sort_affecting_at で復元される) で、event ファイルは status 復元用に state loop が従来どおり
2440
+ * 書く (その prompt_submit の at は chatSessionEventForTick が受付時刻へ揃える)。直接経路が
2441
+ * ファイルを書かないので、fs.watch の読み落とし・並行 rename・kill 中の保留書き込みといった
2442
+ * ファイル由来の競合そのものが無い。
2443
+ *
2444
+ * bridge が入力を拒否した場合 (empty / queue dead 等) も publish 済みのまま取り消さない:
2445
+ * 並び順の定義は「最後に人間が送った順」で、送信操作自体は起きているため (仕様)。
2446
+ */
2447
+ export async function notifyChatPromptSubmit({
2448
+ cwd,
2449
+ cliKind,
2450
+ at,
2451
+ client,
2452
+ readinessTracker,
2453
+ plugins,
2454
+ logger,
2455
+ listSessionStatesFn = listSessionStates,
2456
+ publishFn = publishSessionEvent,
2457
+ }) {
2458
+ if (!cwd || !client) return
2459
+ try {
2460
+ const states = await listSessionStatesFn({ plugins, logger })
2461
+ for (const s of states) {
2462
+ if (s.cwd !== cwd || cliKindForSessionName(s.session_name) !== cliKind) continue
2463
+ try {
2464
+ await publishFn({ client, readinessTracker, logger }, s.session_name, "prompt_submit", at, { fresh: true })
2465
+ } catch (err) {
2466
+ logger?.warn?.({ err: err?.message, cwd, cliKind, session_name: s.session_name }, "notifyChatPromptSubmit publish failed")
2467
+ }
2468
+ }
2469
+ } catch (err) {
2470
+ logger?.warn?.({ err: err?.message, cwd, cliKind }, "notifyChatPromptSubmit failed")
2471
+ }
2472
+ }
2473
+
2474
+ /**
2475
+ * chat signal から、この tick で書く session-event を導出する。
2476
+ * FIFO で前ターンの stop 後に次ターンの processing を観測しても、prompt_submit の at は
2477
+ * 最後に人間が送った時刻を超えない。直接 publish が失敗 (tmux 走査不能等) しても edge を抑止しないため、
2478
+ * processing 観測時に同じ at で回復できる。同値 at の重複は ingest の単調ガードで無害。
2479
+ *
2480
+ * @returns {{ev: string, at: number}|null}
2481
+ */
2482
+ export function chatSessionEventForTick({ chat, prevEv, prevTurnAt, directAt }) {
2483
+ const ev = chat.inputPending
2484
+ ? "input_required"
2485
+ : chat.status === "processing"
2486
+ ? "prompt_submit"
2487
+ : "stop"
2488
+ if (!(chat.turnAt > prevTurnAt && ev !== prevEv)) return null
2489
+ const at = ev === "prompt_submit" && typeof directAt === "number" && directAt > 0
2490
+ ? directAt
2491
+ : chat.turnAt
2492
+ return { ev, at }
2493
+ }
2494
+
2417
2495
  /**
2418
2496
  * turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
2419
2497
  *
@@ -2588,17 +2666,19 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2588
2666
  //
2589
2667
  // 確認待ち (inputPending) は専用 event "input_required"、生成中は "prompt_submit"、
2590
2668
  // それ以外は "stop"。並び順に効くのは computeSortAffecting により prompt_submit だけ。
2591
- const ev = chat.inputPending
2592
- ? "input_required"
2593
- : chat.status === "processing"
2594
- ? "prompt_submit"
2595
- : "stop"
2596
2669
  const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
2597
2670
  const prevEv = lastChatEventByName.get(s.session_name)
2598
- if (chat.turnAt > prevTurnAt && ev !== prevEv) {
2671
+ const eventForTick = chatSessionEventForTick({
2672
+ chat,
2673
+ prevEv,
2674
+ prevTurnAt,
2675
+ directAt: s.cwd ? getPromptAcceptedAt(s.cwd, sessionCliKind) : undefined,
2676
+ })
2677
+ if (eventForTick) {
2678
+ const { ev, at } = eventForTick
2599
2679
  lastTurnAtByName.set(s.session_name, chat.turnAt)
2600
2680
  lastChatEventByName.set(s.session_name, ev)
2601
- writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
2681
+ writeSessionEventFile(s.session_name, ev, at).catch(() => {})
2602
2682
  }
2603
2683
  }
2604
2684
  // 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
@@ -3126,6 +3206,7 @@ function inputLifecycleRejection(ctx) {
3126
3206
  * テストから直接呼べるよう dispatch から切り出して export する。
3127
3207
  */
3128
3208
  export function handleClaudeChatInput(msg, ctx) {
3209
+ const acceptedAt = Date.now()
3129
3210
  const stream_id = msg.stream_id
3130
3211
  const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
3131
3212
  if (!ctx.claudeBridge) {
@@ -3174,6 +3255,26 @@ export function handleClaudeChatInput(msg, ctx) {
3174
3255
  }
3175
3256
  return undefined
3176
3257
  }
3258
+ // cwd は dispatch 前に確定する (Promise 解決後に引くと、その間に stream が別 cwd へ再 attach
3259
+ // された場合に別行へ記録する)。受付時刻の記録と並び順の publish も dispatch **前** に行う:
3260
+ // Claude の resident reconcile / Codex の turn/start RPC が未解決の間に assistant / turn/started
3261
+ // が先に届いて state loop が processing を観測しても、記録済みなら観測時刻ではなく受付時刻で
3262
+ // 書かれる。bridge が拒否しても取り消さない (仕様。notifyChatPromptSubmit の JSDoc)。
3263
+ const cwdSnapshot = ctx.claudeBridge.getCwd?.(stream_id) ?? null
3264
+ if (cwdSnapshot) {
3265
+ recordPromptAccepted(cwdSnapshot, "claude", acceptedAt)
3266
+ notifyChatPromptSubmit({
3267
+ cwd: cwdSnapshot,
3268
+ cliKind: "claude",
3269
+ at: acceptedAt,
3270
+ plugins: ctx.plugins,
3271
+ logger: ctx.logger,
3272
+ client: ctx.client,
3273
+ readinessTracker: ctx.readinessTracker,
3274
+ listSessionStatesFn: ctx._listSessionStates || listSessionStates,
3275
+ publishFn: ctx._publishSessionEvent || publishSessionEvent,
3276
+ }).catch(() => {})
3277
+ }
3177
3278
  const p = ctx.claudeBridge
3178
3279
  .input({
3179
3280
  stream_id,
@@ -3398,6 +3499,7 @@ export function buildAttachHistoryPromise({
3398
3499
  * テストから直接呼べるよう dispatch から切り出して export する。
3399
3500
  */
3400
3501
  export function handleCodexChatInput(msg, ctx) {
3502
+ const acceptedAt = Date.now()
3401
3503
  const stream_id = msg.stream_id
3402
3504
  const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
3403
3505
  if (!ctx.codexBridge) {
@@ -3438,6 +3540,23 @@ export function handleCodexChatInput(msg, ctx) {
3438
3540
  })
3439
3541
  return undefined
3440
3542
  }
3543
+ // claude 側と同じ理由で dispatch 前に cwd を確定し、受付時刻の記録と publish を行う
3544
+ // (handleClaudeChatInput のコメント参照)。
3545
+ const cwdSnapshot = ctx.codexBridge.getCwd?.(stream_id) ?? null
3546
+ if (cwdSnapshot) {
3547
+ recordPromptAccepted(cwdSnapshot, "codex", acceptedAt)
3548
+ notifyChatPromptSubmit({
3549
+ cwd: cwdSnapshot,
3550
+ cliKind: "codex",
3551
+ at: acceptedAt,
3552
+ plugins: ctx.plugins,
3553
+ logger: ctx.logger,
3554
+ client: ctx.client,
3555
+ readinessTracker: ctx.readinessTracker,
3556
+ listSessionStatesFn: ctx._listSessionStates || listSessionStates,
3557
+ publishFn: ctx._publishSessionEvent || publishSessionEvent,
3558
+ }).catch(() => {})
3559
+ }
3441
3560
  const p = ctx.codexBridge
3442
3561
  .input({
3443
3562
  stream_id,