@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 +1 -1
- package/src/claude-history.mjs +81 -0
- package/src/main.mjs +35 -0
- package/src/tmux.mjs +11 -3
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
1294
|
+
return { ok: false, error: "menu still visible after answering", answered }
|
|
1287
1295
|
}
|
|
1288
1296
|
|
|
1289
1297
|
/**
|