@cocorograph/hub-agent 0.8.9 → 0.8.11
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 +24 -3
- package/src/claude-stream-bridge.mjs +68 -42
- package/src/codex-stream-bridge.mjs +223 -142
- package/src/main.mjs +93 -314
- package/src/sidebar-message-snapshot.mjs +362 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { open, readdir, realpath, stat } from "node:fs/promises"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { extractLastTurnTexts, jsonlPath, LAST_TURN_TEXT_MAX } from "./claude-history.mjs"
|
|
6
|
+
import { resolveProjectsRoot } from "./profiles.mjs"
|
|
7
|
+
import * as codexProfiles from "./codex-profiles.mjs"
|
|
8
|
+
import { lookupInjectSource } from "./inject-source-store.mjs"
|
|
9
|
+
import { SECRET_PLACEHOLDER, redactSecrets } from "./tool-result-mask.mjs"
|
|
10
|
+
|
|
11
|
+
const MAX_TARGETS = 100
|
|
12
|
+
const READ_CONCURRENCY = 8
|
|
13
|
+
const TAIL_CHUNK = 64 * 1024
|
|
14
|
+
const MAX_SCAN = 8 * 1024 * 1024
|
|
15
|
+
const MAX_LINE = 1024 * 1024
|
|
16
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
17
|
+
|
|
18
|
+
const textCache = new Map()
|
|
19
|
+
const textInFlight = new Map()
|
|
20
|
+
const rolloutPathCache = new Map()
|
|
21
|
+
|
|
22
|
+
function sanitizeText(value) {
|
|
23
|
+
if (typeof value !== "string" || !value.trim()) return ""
|
|
24
|
+
let masked = redactSecrets(value)
|
|
25
|
+
const openerAt = masked.indexOf("-----BEGIN")
|
|
26
|
+
if (openerAt >= 0) masked = masked.slice(0, openerAt) + SECRET_PLACEHOLDER
|
|
27
|
+
return masked.trim().replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function itemText(item) {
|
|
31
|
+
const content = item?.content
|
|
32
|
+
if (typeof content === "string") return content
|
|
33
|
+
if (!Array.isArray(content)) return ""
|
|
34
|
+
return content
|
|
35
|
+
.map((block) =>
|
|
36
|
+
block && typeof block === "object" && typeof block.text === "string"
|
|
37
|
+
? block.text
|
|
38
|
+
: "",
|
|
39
|
+
)
|
|
40
|
+
.join("\n")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function textsFromCodexItems(items, { isInjected } = {}) {
|
|
44
|
+
let assistant = ""
|
|
45
|
+
if (!Array.isArray(items)) return { user: "", assistant }
|
|
46
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
47
|
+
const item = items[i]
|
|
48
|
+
if (item?.type === "agentMessage" && !assistant) {
|
|
49
|
+
assistant = sanitizeText(itemText(item))
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (item?.type !== "userMessage") continue
|
|
53
|
+
const user = sanitizeText(itemText(item))
|
|
54
|
+
if (!user) continue
|
|
55
|
+
let injected = item.source === "peer" || item.source === "orchestrator"
|
|
56
|
+
if (!injected && typeof isInjected === "function") {
|
|
57
|
+
try {
|
|
58
|
+
injected = (await isInjected(user)) === true
|
|
59
|
+
} catch {
|
|
60
|
+
// 判定不能なら通常入力として残す。
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (injected) {
|
|
64
|
+
assistant = ""
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
return { user, assistant }
|
|
68
|
+
}
|
|
69
|
+
return { user: "", assistant }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function codexLineMessage(line) {
|
|
73
|
+
if (!line) return null
|
|
74
|
+
let obj
|
|
75
|
+
try {
|
|
76
|
+
obj = JSON.parse(line)
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
if (obj?.type !== "event_msg" || !obj.payload) return null
|
|
81
|
+
if (obj.payload.type === "agent_message") {
|
|
82
|
+
return { role: "assistant", text: sanitizeText(obj.payload.message) }
|
|
83
|
+
}
|
|
84
|
+
if (obj.payload.type === "user_message") {
|
|
85
|
+
return { role: "user", text: sanitizeText(obj.payload.message) }
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function extractCodexLastTurnTexts(filePath, { isInjected } = {}) {
|
|
91
|
+
let assistant = ""
|
|
92
|
+
let handle
|
|
93
|
+
try {
|
|
94
|
+
handle = await open(filePath, "r")
|
|
95
|
+
const fileStat = await handle.stat()
|
|
96
|
+
let pos = fileStat.size
|
|
97
|
+
let carry = null
|
|
98
|
+
let scanned = 0
|
|
99
|
+
while (pos > 0 && scanned < MAX_SCAN) {
|
|
100
|
+
const len = Math.min(TAIL_CHUNK, pos, MAX_SCAN - scanned)
|
|
101
|
+
pos -= len
|
|
102
|
+
const buf = Buffer.allocUnsafe(len)
|
|
103
|
+
const { bytesRead } = await handle.read(buf, 0, len, pos)
|
|
104
|
+
scanned += bytesRead
|
|
105
|
+
const region = carry?.length
|
|
106
|
+
? Buffer.concat([buf.subarray(0, bytesRead), carry])
|
|
107
|
+
: buf.subarray(0, bytesRead)
|
|
108
|
+
const parts = []
|
|
109
|
+
let start = 0
|
|
110
|
+
for (let i = 0; i < region.length; i++) {
|
|
111
|
+
if (region[i] === 0x0a) {
|
|
112
|
+
parts.push(region.subarray(start, i))
|
|
113
|
+
start = i + 1
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
parts.push(region.subarray(start))
|
|
117
|
+
carry = pos > 0 ? parts.shift() : null
|
|
118
|
+
if (carry?.length > MAX_LINE) carry = null
|
|
119
|
+
|
|
120
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
121
|
+
if (parts[i].length === 0 || parts[i].length > MAX_LINE) continue
|
|
122
|
+
const message = codexLineMessage(parts[i].toString("utf8"))
|
|
123
|
+
if (!message?.text) continue
|
|
124
|
+
if (message.role === "assistant") {
|
|
125
|
+
if (!assistant) assistant = message.text
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
let injected = false
|
|
129
|
+
if (typeof isInjected === "function") {
|
|
130
|
+
try {
|
|
131
|
+
injected = (await isInjected(message.text)) === true
|
|
132
|
+
} catch {
|
|
133
|
+
// 判定不能なら通常入力として残す。
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (injected) {
|
|
137
|
+
assistant = ""
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
return { user: message.text, assistant }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { user: "", assistant }
|
|
144
|
+
} catch {
|
|
145
|
+
return { user: "", assistant: "" }
|
|
146
|
+
} finally {
|
|
147
|
+
await handle?.close().catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function findCodexRolloutFile(threadId, sessionsDir) {
|
|
152
|
+
if (!UUID_RE.test(threadId) || !sessionsDir) return null
|
|
153
|
+
const cacheKey = `${sessionsDir}\0${threadId}`
|
|
154
|
+
const cached = rolloutPathCache.get(cacheKey)
|
|
155
|
+
if (cached) {
|
|
156
|
+
try {
|
|
157
|
+
await stat(cached)
|
|
158
|
+
return cached
|
|
159
|
+
} catch {
|
|
160
|
+
rolloutPathCache.delete(cacheKey)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const suffix = `-${threadId}.jsonl`
|
|
164
|
+
const years = await readdir(sessionsDir).catch(() => [])
|
|
165
|
+
for (const year of years.filter((v) => /^\d{4}$/.test(v)).sort().reverse()) {
|
|
166
|
+
const yearDir = path.join(sessionsDir, year)
|
|
167
|
+
const months = await readdir(yearDir).catch(() => [])
|
|
168
|
+
for (const month of months.sort().reverse()) {
|
|
169
|
+
const monthDir = path.join(yearDir, month)
|
|
170
|
+
const days = await readdir(monthDir).catch(() => [])
|
|
171
|
+
for (const day of days.sort().reverse()) {
|
|
172
|
+
const dayDir = path.join(monthDir, day)
|
|
173
|
+
const files = await readdir(dayDir).catch(() => [])
|
|
174
|
+
const match = files.find((file) => file.endsWith(suffix))
|
|
175
|
+
if (!match) continue
|
|
176
|
+
const found = path.join(dayDir, match)
|
|
177
|
+
rolloutPathCache.set(cacheKey, found)
|
|
178
|
+
return found
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readCached(filePath, extract, { maxAgeMs = Infinity } = {}) {
|
|
186
|
+
if (!filePath) return { user: "", assistant: "" }
|
|
187
|
+
let fileStat
|
|
188
|
+
try {
|
|
189
|
+
fileStat = await stat(filePath)
|
|
190
|
+
} catch {
|
|
191
|
+
return { user: "", assistant: "" }
|
|
192
|
+
}
|
|
193
|
+
const version = `${fileStat.mtimeMs}:${fileStat.size}:${fileStat.ctimeMs}`
|
|
194
|
+
const cached = textCache.get(filePath)
|
|
195
|
+
if (
|
|
196
|
+
cached &&
|
|
197
|
+
cached.version === version &&
|
|
198
|
+
Date.now() - cached.checkedAt < maxAgeMs
|
|
199
|
+
) {
|
|
200
|
+
return cached.texts
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 複数タブの同時 snapshot は同じ版の抽出 1 本へ畳む。版が違う要求は古い
|
|
204
|
+
// in-flight に合流させず、追記後の本文を独立して読む。
|
|
205
|
+
const inFlightKey = `${filePath}\0${version}`
|
|
206
|
+
let pending = textInFlight.get(inFlightKey)
|
|
207
|
+
if (!pending) {
|
|
208
|
+
pending = extract(filePath).finally(() => {
|
|
209
|
+
textInFlight.delete(inFlightKey)
|
|
210
|
+
})
|
|
211
|
+
textInFlight.set(inFlightKey, pending)
|
|
212
|
+
}
|
|
213
|
+
const texts = await pending
|
|
214
|
+
|
|
215
|
+
// 抽出中に追記・置換された結果を開始時の版としてキャッシュしない。
|
|
216
|
+
try {
|
|
217
|
+
const after = await stat(filePath)
|
|
218
|
+
const afterVersion = `${after.mtimeMs}:${after.size}:${after.ctimeMs}`
|
|
219
|
+
if (afterVersion === version) {
|
|
220
|
+
textCache.set(filePath, {
|
|
221
|
+
version,
|
|
222
|
+
checkedAt: Date.now(),
|
|
223
|
+
texts,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// 読み終えた直後の削除は今回結果だけ返し、cache には残さない。
|
|
228
|
+
}
|
|
229
|
+
while (textCache.size > 512) textCache.delete(textCache.keys().next().value)
|
|
230
|
+
return texts
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizedTarget(raw) {
|
|
234
|
+
if (!raw || typeof raw !== "object") return null
|
|
235
|
+
const sessionName = typeof raw.session_name === "string" ? raw.session_name.trim() : ""
|
|
236
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd.trim() : ""
|
|
237
|
+
const cliKind = raw.cli_kind === "codex" ? "codex" : raw.cli_kind === "claude" ? "claude" : ""
|
|
238
|
+
const conversationId =
|
|
239
|
+
typeof raw.conversation_id === "string" ? raw.conversation_id.trim() : ""
|
|
240
|
+
const profileId = typeof raw.profile_id === "string" ? raw.profile_id.trim() : undefined
|
|
241
|
+
if (!sessionName || !cwd || !cliKind || !UUID_RE.test(conversationId)) return null
|
|
242
|
+
return { sessionName, cwd, cliKind, conversationId, profileId }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function defaultIsAllowedCwd(cwd) {
|
|
246
|
+
const configuredBase = process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
|
|
247
|
+
try {
|
|
248
|
+
const [resolvedCwd, resolvedBase] = await Promise.all([realpath(cwd), realpath(configuredBase)])
|
|
249
|
+
if (!resolvedCwd.startsWith(`${resolvedBase}${path.sep}`)) return false
|
|
250
|
+
const parts = path.relative(resolvedBase, resolvedCwd).split(path.sep)
|
|
251
|
+
return (
|
|
252
|
+
parts.length === 1 ||
|
|
253
|
+
(parts.length === 4 && parts[1] === ".claude" && parts[2] === "worktrees")
|
|
254
|
+
)
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function readSidebarMessageSnapshot(
|
|
261
|
+
targets,
|
|
262
|
+
{
|
|
263
|
+
resolveClaudeRoot = resolveProjectsRoot,
|
|
264
|
+
resolveCodexRoot = codexProfiles.resolveSessionsDir,
|
|
265
|
+
liveCodexSession,
|
|
266
|
+
isAllowedCwd = defaultIsAllowedCwd,
|
|
267
|
+
lookupInjected = lookupInjectSource,
|
|
268
|
+
extractClaude = extractLastTurnTexts,
|
|
269
|
+
extractCodex = extractCodexLastTurnTexts,
|
|
270
|
+
} = {},
|
|
271
|
+
) {
|
|
272
|
+
const safeTargets = (Array.isArray(targets) ? targets : [])
|
|
273
|
+
.slice(0, MAX_TARGETS)
|
|
274
|
+
.map(normalizedTarget)
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
|
|
277
|
+
const readTarget = async (target) => {
|
|
278
|
+
try {
|
|
279
|
+
if (!(await isAllowedCwd(target.cwd))) throw new Error("cwd outside workspace roots")
|
|
280
|
+
const isInjected = async (text) =>
|
|
281
|
+
Boolean(await lookupInjected(target.cwd, text))
|
|
282
|
+
let texts = { user: "", assistant: "" }
|
|
283
|
+
if (target.cliKind === "claude") {
|
|
284
|
+
const { projectsRoot, profileId } = await resolveClaudeRoot(target.profileId)
|
|
285
|
+
const filePath = jsonlPath({
|
|
286
|
+
cwd: target.cwd,
|
|
287
|
+
session_id: target.conversationId,
|
|
288
|
+
projectsRoot,
|
|
289
|
+
})
|
|
290
|
+
texts = await readCached(
|
|
291
|
+
filePath,
|
|
292
|
+
(fp) => extractClaude(fp, { isInjected }),
|
|
293
|
+
{ maxAgeMs: 2_000 },
|
|
294
|
+
)
|
|
295
|
+
return {
|
|
296
|
+
session_name: target.sessionName,
|
|
297
|
+
cli_kind: "claude",
|
|
298
|
+
conversation_id: target.conversationId,
|
|
299
|
+
profile_id: profileId,
|
|
300
|
+
...(texts.user ? { user: texts.user } : {}),
|
|
301
|
+
...(texts.assistant ? { assistant: texts.assistant } : {}),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const live = liveCodexSession?.(target)
|
|
306
|
+
if (live && live.threadId === target.conversationId) {
|
|
307
|
+
texts = await textsFromCodexItems(live.historyItems, { isInjected })
|
|
308
|
+
}
|
|
309
|
+
const { sessionsDir, profileId } = await resolveCodexRoot(target.profileId)
|
|
310
|
+
if (!texts.user || !texts.assistant) {
|
|
311
|
+
const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
|
|
312
|
+
const diskTexts = await readCached(
|
|
313
|
+
filePath,
|
|
314
|
+
(fp) => extractCodex(fp, { isInjected }),
|
|
315
|
+
{ maxAgeMs: 2_000 },
|
|
316
|
+
)
|
|
317
|
+
texts = {
|
|
318
|
+
user: texts.user || diskTexts.user,
|
|
319
|
+
// live に user があるのに assistant が無い = 生成中。前ターンの disk assistant
|
|
320
|
+
// で埋めると別ターン混在になるため、disk は live user 自体が無いときだけ使う。
|
|
321
|
+
assistant: texts.user ? texts.assistant : diskTexts.assistant,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
session_name: target.sessionName,
|
|
326
|
+
cli_kind: "codex",
|
|
327
|
+
conversation_id: target.conversationId,
|
|
328
|
+
profile_id: profileId,
|
|
329
|
+
...(texts.user ? { user: texts.user } : {}),
|
|
330
|
+
...(texts.assistant ? { assistant: texts.assistant } : {}),
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// 1 件の profile / 履歴 I/O 失敗で、他セッションの表示まで消さない。
|
|
334
|
+
return {
|
|
335
|
+
session_name: target.sessionName,
|
|
336
|
+
cli_kind: target.cliKind,
|
|
337
|
+
conversation_id: target.conversationId,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const messages = new Array(safeTargets.length)
|
|
343
|
+
let cursor = 0
|
|
344
|
+
const workers = Array.from(
|
|
345
|
+
{ length: Math.min(READ_CONCURRENCY, safeTargets.length) },
|
|
346
|
+
async () => {
|
|
347
|
+
for (;;) {
|
|
348
|
+
const index = cursor++
|
|
349
|
+
if (index >= safeTargets.length) return
|
|
350
|
+
messages[index] = await readTarget(safeTargets[index])
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
await Promise.all(workers)
|
|
355
|
+
return messages
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function _resetSidebarMessageSnapshotCachesForTest() {
|
|
359
|
+
textCache.clear()
|
|
360
|
+
textInFlight.clear()
|
|
361
|
+
rolloutPathCache.clear()
|
|
362
|
+
}
|