@cocorograph/hub-agent 0.7.24 → 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/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, {
|