/** * @file agent-edits.js * @description The files a Claude subagent wrote or edited, read from its own * transcript (`agent-.jsonl`), so the parent's chat can count them in its * turn's "Edited N files" card (issue #86: a skill that hands its work to * subagents showed only the parent's own edits). Each file's +/- lines come * from the same `patchCounts` the chat uses; a failed call counts nothing; a * subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is * read through `readLines` (a member's transcript only as that member's), and * the answer is cached by path + size + mtime, so a finished subagent is read * once however often its parent's chat is polled. `turnCarry` does the same * for the part of a turn the chat's page of messages cut off. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const { patchCounts, agentInfo } = require("./transcript-message"); const { getSubagentTranscriptPath, findSubagentTranscriptPath, getSnapshotSubagentTranscriptPath, } = require("./claude-home"); const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); const AGENT_TOOLS = new Set(["Agent", "Task"]); const MAX_DEPTH = 3; const CACHE_MAX = 500; // path → { key: ":", edits, agents } const cache = new Map(); /** One transcript's own edits and the subagents it started. */ async function scan(file) { const { readLines } = require("./run-as"); const pending = new Map(); // tool_use id → file path const edits = new Map(); // path → { added, removed, counted } const agents = []; for await (const line of readLines(file)) { if (!line.trim()) continue; let entry; try { entry = JSON.parse(line); } catch { continue; } const content = entry.message?.content; if (!Array.isArray(content)) continue; if (entry.type === "assistant") { for (const b of content) { if (b?.type !== "tool_use" || !EDIT_TOOLS.has(b.name) || !b.id) continue; const p = b.input?.file_path ?? b.input?.notebook_path; if (typeof p === "string" && p) pending.set(b.id, p); } } else if (entry.type === "user") { for (const b of content) { if (b?.type !== "tool_result") continue; const agent = agentInfo(entry.toolUseResult); if (agent) agents.push(agent.id); const p = pending.get(b.tool_use_id); if (!p || b.is_error) continue; const diff = patchCounts(entry.toolUseResult); const f = edits.get(p) ?? { added: 0, removed: 0, counted: false }; f.added += diff?.added ?? 0; f.removed += diff?.removed ?? 0; f.counted ||= !!diff; edits.set(p, f); } } } return { edits, agents }; } async function scanCached(file) { const st = await fs.promises.stat(file); const key = `${st.size}:${st.mtimeMs}`; const hit = cache.get(file); if (hit && hit.key === key) return hit; const fresh = { key, ...(await scan(file)) }; cache.delete(file); cache.set(file, fresh); if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); return fresh; } const transcriptOf = (sessionId, cwd, agentId) => getSubagentTranscriptPath(sessionId, cwd, agentId) || findSubagentTranscriptPath(sessionId, agentId, null, cwd) || getSnapshotSubagentTranscriptPath(sessionId, agentId); /** * The files subagent `agentId` of session `sessionId` (and its own subagents) * changed, as `[{ path, added, removed, counted }]` in first-edit order; an * empty list when its transcript cannot be found or read. */ async function agentEdits(sessionId, cwd, agentId, depth = 0, seen = new Set()) { if (seen.has(agentId)) return []; seen.add(agentId); let file; let got; try { file = transcriptOf(sessionId, cwd, agentId); if (!file) return []; got = await scanCached(file); } catch { return []; } const out = new Map(); const add = (p, f) => { const o = out.get(p) ?? { added: 0, removed: 0, counted: false }; o.added += f.added; o.removed += f.removed; o.counted ||= f.counted; out.set(p, o); }; got.edits.forEach((f, p) => add(p, f)); if (depth < MAX_DEPTH) { for (const id of got.agents) { for (const f of await agentEdits(sessionId, cwd, id, depth + 1, seen)) add(f.path, f); } } return [...out].map(([path, f]) => ({ path, ...f })); } /** Puts `edits` on every Agent/Task result in `messages` (in place). */ async function attachAgentEdits(messages, sessionId, cwd) { for (const m of messages) { for (const c of m.content ?? []) { if (c.type !== "tool_result" || !c.agent?.id || c.is_error) continue; const edits = await agentEdits(sessionId, cwd, c.agent.id); if (edits.length) c.edits = edits; } } } /** A human prompt: the line that starts a turn (the chat's own rule). */ const isPrompt = (m) => (m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" && (m.content ?? []).some((c) => c.type === "text"); /** * The edits of the turn a page of the chat starts in the middle of. The route * keeps only the newest `limit` messages; every message it drops goes through * `drop()`, which keeps the file changes since the last prompt it dropped. * `result(window)` then answers the files that turn changed BEFORE the page's * first message — `[{ path, added, removed, counted }]`, or null when the page * starts on a prompt or nothing was changed — so the turn's "Edited N files" * card is whole although its start was never sent (issue #86: a 120-message * turn showed 1 of its files). */ function turnCarry() { let pending = new Map(); // dropped Write/Edit call id → path let pendingAgents = new Set(); // dropped Agent/Task call ids let files = new Map(); let agents = []; const add = (p, f) => { const o = files.get(p) ?? { added: 0, removed: 0, counted: false }; o.added += f?.added ?? 0; o.removed += f?.removed ?? 0; o.counted ||= !!f && f.counted !== false; files.set(p, o); }; return { drop(m) { if (!m) return; if (isPrompt(m)) { pending = new Map(); pendingAgents = new Set(); files = new Map(); agents = []; return; } for (const c of m.content ?? []) { if (c.type === "tool_use" && c.id) { if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id); if (!EDIT_TOOLS.has(c.name)) continue; const p = c.input?.file_path ?? c.input?.notebook_path; if (typeof p === "string" && p) pending.set(c.id, p); } else if (c.type === "tool_result") { if (c.agent?.id && !c.is_error) agents.push(c.agent.id); pendingAgents.delete(c.id); const p = pending.get(c.id); if (!p) continue; pending.delete(c.id); if (!c.is_error) add(p, c.diff ?? null); } } }, async result(window, sessionId, cwd) { if (!window.length || isPrompt(window[0])) return null; // A call dropped whose result is on the page: the page shows the result // with no call to hang it on, so it counts here. for (const m of window) { for (const c of m.content ?? []) { if (c.type !== "tool_result" || c.is_error) continue; const p = pending.get(c.id); if (p) add(p, c.diff ?? null); if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e); } } for (const id of agents) { for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e); } return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null; }, }; } module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache };