@cocorograph/hub-agent 0.7.23 → 0.7.25

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.7.23",
3
+ "version": "0.7.25",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -341,6 +341,87 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
341
341
  * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null, lastNotifiedAt?: number|null, now?: number|null, reNotifyMs?: number}} args
342
342
  * @returns {{rotated: boolean, newSessionId?: string}}
343
343
  */
344
+ /**
345
+ * 回転候補 jsonl が「bound TUI セッションの本物の /clear 先」か「別アクティビティの jsonl」かを
346
+ * head 4KB だけ読んで判定する純粋ヘルパ (2a 根治)。
347
+ *
348
+ * 背景: 同一 cwd-encoded dir には bound TUI 以外の jsonl が混入し得る:
349
+ * - Task tool subagent (`parent_uuid` / `parentUuid` 付き)
350
+ * - SDK headless `claude -p` (末尾近傍に `type=result`)
351
+ * - 並走 cockpit / 別ターミナル起動の別 TUI (cwd は一致するため弱い signal)
352
+ * - 既存 jsonl が touch されて mtime 最新化 (cwd が viewCwd と不一致なケースは cross-cwd 汚染)
353
+ * これらに対して decideSessionRotation は newest≠viewing で無条件発火するため、ローテに乗ると
354
+ * viewer が別 jsonl へ rotate-bind されて /clear 後の三点リーダー固着・誤生成中表示を起こす。
355
+ *
356
+ * 検出基準 (どれか 1 つでも該当したら reject = 抑止):
357
+ * - `parent_uuid` / `parentUuid` / `parent_session_id` / `parentSessionId` (subagent marker)
358
+ * - `type === 'result'` (SDK headless の完了 marker。先頭 4KB 内に現れたら頭 0KB の極短セッション)
359
+ * - `cwd` フィールドが `viewCwd` と完全不一致 (cross-cwd 汚染)
360
+ *
361
+ * 失敗安全: head 読み失敗 / parse 失敗時は accept (旧挙動 = 即時 rotate へ degrade)。
362
+ * 「/clear が壊れる」リスクは取らないため、不確実時は安全側 (accept) に倒す。
363
+ *
364
+ * @param {{candidatePath?: string|null, viewCwd?: string|null, readImpl?: (p:string,n:number)=>Promise<string|null>, logger?: import('pino').Logger}} args
365
+ * @returns {Promise<{accept: boolean, reason: string}>}
366
+ */
367
+ export async function validateRotationCandidate({
368
+ candidatePath,
369
+ viewCwd,
370
+ readImpl,
371
+ logger,
372
+ } = {}) {
373
+ if (!candidatePath || !viewCwd) {
374
+ return { accept: true, reason: "missing_args" }
375
+ }
376
+ let text
377
+ try {
378
+ if (typeof readImpl === "function") {
379
+ text = await readImpl(candidatePath, 4096)
380
+ } else {
381
+ const handle = await open(candidatePath, "r")
382
+ try {
383
+ const buf = Buffer.allocUnsafe(4096)
384
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0)
385
+ text = buf.toString("utf-8", 0, bytesRead)
386
+ } finally {
387
+ await handle.close().catch(() => {})
388
+ }
389
+ }
390
+ } catch (err) {
391
+ logger?.warn(
392
+ { err: err?.message, candidatePath },
393
+ "rotation candidate head read failed → accept (degrade to legacy behavior)",
394
+ )
395
+ return { accept: true, reason: `read_failed:${err?.code || "unknown"}` }
396
+ }
397
+ if (!text) return { accept: true, reason: "empty_text" }
398
+ for (const line of text.split("\n")) {
399
+ if (!line) continue
400
+ let obj
401
+ try {
402
+ obj = JSON.parse(line)
403
+ } catch {
404
+ continue
405
+ }
406
+ if (!obj || typeof obj !== "object") continue
407
+ if (
408
+ obj.parent_uuid ||
409
+ obj.parentUuid ||
410
+ obj.parent_session_id ||
411
+ obj.parentSessionId
412
+ ) {
413
+ return { accept: false, reason: "subagent_marker" }
414
+ }
415
+ if (obj.type === "result") {
416
+ return { accept: false, reason: "headless_result_marker" }
417
+ }
418
+ if (typeof obj.cwd === "string" && obj.cwd && obj.cwd !== viewCwd) {
419
+ return { accept: false, reason: "cwd_mismatch" }
420
+ }
421
+ }
422
+ return { accept: true, reason: "passed" }
423
+ }
424
+
344
425
  export function decideSessionRotation({
345
426
  viewingSessionId,
346
427
  newestSessionId,
package/src/main.mjs CHANGED
@@ -30,7 +30,9 @@ import { requestSelfUninstall } from "./service-install.mjs"
30
30
  import {
31
31
  decideSessionRotation,
32
32
  fetchSessionHistory,
33
+ jsonlPath,
33
34
  listSessions,
35
+ validateRotationCandidate,
34
36
  } from "./claude-history.mjs"
35
37
  import { listAgents } from "./agents.mjs"
36
38
  import { listSkills } from "./skills.mjs"
@@ -2240,6 +2242,39 @@ async function dispatch(msg, ctx) {
2240
2242
  return
2241
2243
  }
2242
2244
  if (!rotated) return
2245
+
2246
+ // 2a 根治: rotate-bind の前に「候補が本物の /clear 先か」を head 4KB で検証する。
2247
+ // subagent (parent_uuid 等) / SDK headless (type=result) / cwd 不一致のものは
2248
+ // 抑止し、別アクティビティ jsonl への汚染 bind を断つ。検証 NG 時は capped と同様に
2249
+ // tuiRotationNotified を進めて連発を防ぐ。検証失敗 (read error) は accept = 旧挙動。
2250
+ const candidatePath = jsonlPath({
2251
+ cwd: viewCwd,
2252
+ session_id: newSessionId,
2253
+ projectsRoot,
2254
+ })
2255
+ const { accept, reason } = await validateRotationCandidate({
2256
+ candidatePath,
2257
+ viewCwd,
2258
+ logger,
2259
+ })
2260
+ if (!accept) {
2261
+ ctx.tuiRotationNotified.set(key, {
2262
+ newId: newSessionId,
2263
+ ts: now,
2264
+ count: newSessionId === prevNewId ? prevCount + 1 : 1,
2265
+ })
2266
+ logger.warn(
2267
+ {
2268
+ session: viewName,
2269
+ cwd: viewCwd,
2270
+ view: viewSid,
2271
+ newest: newestId,
2272
+ reason,
2273
+ },
2274
+ "tui session rotation: suppressed (sibling activity / not a real /clear)",
2275
+ )
2276
+ return
2277
+ }
2243
2278
  // {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
2244
2279
  // newId が変われば count を 1 にリセット、同一なら加算する。
2245
2280
  ctx.tuiRotationNotified.set(key, {
package/src/tmux.mjs CHANGED
@@ -1204,8 +1204,12 @@ export async function answerAskUserQuestion(name, answers, opts = {}) {
1204
1204
  continue
1205
1205
  }
1206
1206
 
1207
+ // 全回答済みなのにメニューが残る = 直前回答の反映ラグ (特に自由入力で Type something の
1208
+ // ラベルが入力文字へ変わり waitChanged が早期復帰した場合)。ここで「質問が余っている」と
1209
+ // 誤判定して ok:false を返すと frontend が回答済みカードを復元してしまう。完了待ち
1210
+ // (final block) へ抜けてメニュー消失を確認する。
1211
+ if (answered >= answers.length) break
1207
1212
  const entry = answers[answered]
1208
- if (!entry) return { ok: false, error: "more questions than answers", answered }
1209
1213
  const labels = Array.isArray(entry.labels) ? entry.labels : []
1210
1214
  const sigBefore = _askMenuSig(menu)
1211
1215
 
@@ -1270,7 +1274,11 @@ export async function answerAskUserQuestion(name, answers, opts = {}) {
1270
1274
  await waitChanged(sigBefore) // 質問遷移 / 消失を確認してから次へ (二重回答防止)
1271
1275
  }
1272
1276
 
1273
- // 最後に "Submit answers" が残っていれば確定し、メニュー消失を検証。
1277
+ // 完了待ち: メニューが消える (= 回答が claude に届いた) まで待つ。"Submit answers"
1278
+ // 残っていれば確定する。消失を確認できたら ok:true。dismissMs 経っても残っている =
1279
+ // 回答が届いていない (真の失敗) ので ok:false を返す。ok の正確性が肝: ok:true なら
1280
+ // 回答後の tool_result を 1-A が検知して cancel でカードを畳む / ok:false なら frontend が
1281
+ // カードを復元して再回答できるようにする。両者が食い違うとカード状態が競合する。
1274
1282
  const finalDl = Date.now() + dismissMs
1275
1283
  while (Date.now() < finalDl) {
1276
1284
  const m = parseAskMenu(await cap())
@@ -1283,7 +1291,7 @@ export async function answerAskUserQuestion(name, answers, opts = {}) {
1283
1291
  { session: name, answered },
1284
1292
  "answerAskUserQuestion: menu still visible after retries",
1285
1293
  )
1286
- return { ok: true, answered }
1294
+ return { ok: false, error: "menu still visible after answering", answered }
1287
1295
  }
1288
1296
 
1289
1297
  /**