UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

627 lines (626 loc) • 38.1 kB
import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { createReadStream } from "node:fs"; import { createInterface } from "node:readline"; import { createHash } from "node:crypto"; import path from "node:path"; import { object, objects } from "./protocol.js"; const claudeRelationCache = new Map(); const copyRows = (rows) => ({ launches: new Map([...rows.launches].map(([id, launch]) => [id, { ...launch }])), skills: new Set(rows.skills), workflows: new Map([...rows.workflows].map(([id, workflow]) => [id, { ...workflow }])), teammates: new Map([...rows.teammates].map(([name, launch]) => [name, { ...launch }])) }); const claudeChildModelCache = new Map(); function cacheClaudeChildModel(file, entry) { claudeChildModelCache.set(file, entry); while (claudeChildModelCache.size > 128) claudeChildModelCache.delete(claudeChildModelCache.keys().next().value); } /** The model is available on the first assistant turn of a Claude sidechain. * A growing transcript without that turn is retried; a known model is final. */ async function claudeChildModel(file) { const metadata = await stat(file), cached = claudeChildModelCache.get(file); if (cached && cached.dev === metadata.dev && cached.ino === metadata.ino && (cached.model !== undefined || metadata.size <= cached.size)) return cached.model; let model; if (metadata.size > 0) { const bytes = Math.min(metadata.size, 65_536); const input = createInterface({ input: createReadStream(file, { start: 0, end: bytes - 1 }), crlfDelay: Infinity }); let lines = 0; for await (const line of input) { ++lines; try { const raw = object(JSON.parse(line)); if (raw.type === "assistant") { const value = object(raw.message).model; if (typeof value === "string" && value.length > 0 && value.length <= 200) model = value; break; } } catch { /* Ignore malformed rows while looking for the first assistant turn. */ } if (lines === 200) break; } } cacheClaudeChildModel(file, { dev: metadata.dev, ino: metadata.ino, size: metadata.size, ...(model !== undefined ? { model } : {}) }); return model; } /** The checkout a Claude sub-agent edits in. Claude Code writes the isolated * worktree into the child's `.meta.json`; an older child falls back to the * `cwd` its own first rows record when that folder is a linked worktree (its * `.git` is a file). A child working in the parent's checkout gets nothing, * and a worktree already removed is not offered. */ export async function claudeChildCheckout(file) { const meta = await readFile(file.slice(0, -".jsonl".length) + ".meta.json", "utf8").then(v => object(JSON.parse(v))).catch(() => ({})); let cwd = typeof meta.worktreePath === "string" && path.isAbsolute(meta.worktreePath) ? meta.worktreePath : undefined; let branch = typeof meta.worktreeBranch === "string" && meta.worktreeBranch ? meta.worktreeBranch.slice(0, 200) : undefined; if (!cwd) { const input = createInterface({ input: createReadStream(file, { start: 0, end: 65_535 }), crlfDelay: Infinity }); let lines = 0; for await (const line of input) { try { const value = object(JSON.parse(line)).cwd; if (typeof value === "string" && path.isAbsolute(value)) { if ((await stat(path.join(value, ".git")).catch(() => undefined))?.isFile()) cwd = value; break; } } catch { /* Keep looking past a malformed row. */ } if (++lines >= 40) break; } input.close(); branch = undefined; } if (!cwd || !(await stat(cwd).catch(() => undefined))?.isDirectory()) return {}; return { cwd, worktreeName: path.basename(cwd).slice(0, 200), ...(branch ? { branch } : {}) }; } async function withClaudeChildModels(relations) { return Promise.all(relations.map(async (relation) => { if (relation.model !== undefined || relation.transcript === undefined) return relation; const model = await claudeChildModel(relation.transcript).catch(() => undefined); return model === undefined ? relation : { ...relation, model }; })); } export async function claudeChildAgents(file, session) { const metadata = await stat(file), signature = `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}`; const cached = claudeRelationCache.get(file); if (cached?.signature === signature && !(cached.recheckAt !== undefined && Date.now() >= cached.recheckAt)) { cached.relations = await withClaudeChildModels(cached.relations); return cached.relations; } // A recheck of an unchanged parent (a launch whose transcript was not there // yet, a background skill or workflow still running) reads only the children. const rows = cached?.signature === signature && cached.rows ? cached.rows : await claudeParentRows(file); return claudeChildRelations(file, session, signature, rows); } async function claudeParentRows(file) { const launches = new Map(); const stops = new Map(); // A background skill (`/code-review` run as `@code-review`) is announced in // a local-command row, not a tool result, and its end is read from its own // transcript (claudeSkillFinished). const skills = new Set(); // A Workflow run, by its task id: its agents are in the run's journal. const workflows = new Map(); // Named teammates (the Agent tool with a `name`) run as their own session // and never post a task-notification: they announce themselves idle in a // teammate-message instead, and may be woken again later. Their file is // `agent-a<name>-<hex>.jsonl` beside the Task sidechains. const teammates = new Map(); const lines = createInterface({ input: createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity }); for await (const line of lines) { try { const raw = object(JSON.parse(line)), result = object(raw.toolUseResult); const blocks = objects(object(raw.message).content); const agentId = String(result.agentId ?? ""), status = String(result.status ?? ""); if (/^[A-Za-z0-9._-]{1,128}$/.test(agentId) && ["async_launched", "running"].includes(status)) { const callId = String(blocks.find(b => b.type === "tool_result")?.tool_use_id ?? ""); if (callId) launches.set(agentId, { path: String(result.description || result.name || "Agent").slice(0, 200), callId, state: "running" }); } // SendMessage to a finished agent resumes it until its next final notification. const resumed = typeof result.resumedAgentId === "string" ? launches.get(result.resumedAgentId) : undefined; if (resumed && typeof result.message === "string" && /^Resuming agent\b/i.test(result.message)) resumed.state = "running"; if (result.taskType === "local_workflow" && status === "async_launched" && typeof result.taskId === "string" && typeof result.runId === "string" && /^wf_[A-Za-z0-9-]{1,64}$/.test(result.runId)) { const callId = String(blocks.find(b => b.type === "tool_result")?.tool_use_id ?? ""); workflows.set(result.taskId, { runId: result.runId, name: String(result.workflowName || "Workflow").slice(0, 200), callId, state: "running" }); } if (raw.type === "system" && typeof raw.content === "string" && raw.content.includes("<forked-skill-launch>")) { const launch = object(JSON.parse(/<forked-skill-launch>([\s\S]*?)<\/forked-skill-launch>/.exec(raw.content)?.[1] ?? "{}")); const id = String(launch.agentId ?? ""); if (/^[A-Za-z0-9._-]{1,128}$/.test(id)) { launches.set(id, { path: String(launch.description || (launch.skillName ? `/${launch.skillName}` : "Skill")).slice(0, 200), callId: `skill:${id}`, state: "running" }); skills.add(id); } } if (raw.type === "assistant") { for (const block of blocks) { if (block.type !== "tool_use") continue; if (block.name === "TaskStop" && typeof block.id === "string" && typeof object(block.input).task_id === "string") { stops.set(block.id, String(object(block.input).task_id)); } if (block.name !== "Agent") continue; const input = object(block.input), name = String(input.name ?? ""); if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || typeof block.id !== "string") continue; teammates.set(name, { path: String(input.description || name).slice(0, 200), callId: block.id.slice(0, 200), state: "running" }); } } // A stop request alone proves nothing. Its successful, matching result // ends the child even when no later task notification is recorded. if (raw.type === "user") { for (const block of blocks) { if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; const task = stops.get(block.tool_use_id); if (!task) continue; stops.delete(block.tool_use_id); const previous = launches.get(task) ?? workflows.get(task); if (previous && block.is_error !== true && result.task_id === task && typeof result.message === "string" && /^Successfully stopped task\b/i.test(result.message)) previous.state = "completed"; } } const content = typeof raw.content === "string" ? raw.content : typeof object(raw.message).content === "string" ? String(object(raw.message).content) : ""; const attachment = object(raw.attachment); const notices = [raw.type === "queue-operation" || raw.type === "user" ? content : "", ...(raw.type === "user" ? blocks.filter(block => block.type === "text" && typeof block.text === "string").map(block => String(block.text)) : []), raw.type === "attachment" && attachment.type === "queued_command" && attachment.commandMode === "task-notification" && typeof attachment.prompt === "string" ? attachment.prompt : ""]; for (const notice of notices) { for (const match of notice.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) { const child = /<task-id>([^<>]{1,128})<\/task-id>/.exec(match[1])?.[1], taskStatus = /<status>([^<>]+)<\/status>/.exec(match[1])?.[1].trim(); // Terminal notifications end only the child named in that envelope. const previous = child && (launches.get(child) ?? workflows.get(child)); if (previous && ["completed", "failed", "cancelled", "canceled", "killed", "stopped"].includes(taskStatus ?? "")) previous.state = "completed"; } } // One row can carry several messages (a shutdown approval and the // system's termination notice together). Idle, an approved shutdown // and termination end a teammate; anything else it says wakes it. for (const match of content.includes("<teammate-message") ? content.matchAll(/<teammate-message teammate_id="([A-Za-z0-9][A-Za-z0-9_-]{0,63})"[^>]*>([\s\S]*?)<\/teammate-message>/g) : []) { const [, from, body] = match; const type = /"type":"([a-z_]+)"/.exec(body)?.[1]; const ended = from === "system" && type === "teammate_terminated" ? /"message":"([A-Za-z0-9][A-Za-z0-9_-]{0,63}) has shut down/.exec(body)?.[1] : undefined; const teammate = teammates.get(ended ?? from); if (teammate) teammate.state = ended || type === "idle_notification" || type === "shutdown_approved" ? "completed" : "running"; } } catch { /* Ignore unrelated/malformed rows. */ } } return { launches, skills, workflows, teammates }; } async function claudeChildRelations(file, session, signature, rows) { // Kept as the parent wrote them: what follows marks finished children. const kept = copyRows(rows); const { launches, workflows, teammates } = copyRows(rows); const relations = []; // Claude Code records the launch in the parent before the child's own // file exists. A launch without a transcript yet is looked for again // shortly, rather than being missed until the parent next changes. // Children whose end the parent does not record (background skills, // workflow agents) are looked at again on a timer too. let awaiting = false, live = false; const root = await realpath(path.join(path.dirname(file), session, "subagents")).catch(() => undefined); for (const [agentId, launch] of launches) { const childFile = root && await realpath(path.join(root, `agent-${agentId}.jsonl`)).catch(() => undefined); if (!childFile || !childFile.startsWith(root + path.sep) || !await claudeChildBelongsTo(childFile, session, agentId)) { if (launch.state === "running") awaiting = true; continue; } // A skill never gets a notification, and any child loses its own when the // session that ran it was restarted or continued: its transcript decides. if (launch.state === "running") { if (await claudeSkillFinished(childFile).catch(() => false)) launch.state = "completed"; else live = true; } const model = await claudeChildModel(childFile).catch(() => undefined); const checkout = await claudeChildCheckout(childFile).catch(() => ({})); relations.push({ id: createHash("sha256").update(`claude\0${session}\0${agentId}`).digest("hex").slice(0, 32), session: agentId, transcript: childFile, provider: "claude", ...launch, ...(model !== undefined ? { model } : {}), ...checkout, children: [] }); } if (teammates.size && root) { const entries = await readdir(root).catch(() => []); const names = entries.filter(n => /^agent-a[A-Za-z0-9][A-Za-z0-9_-]{0,63}-[0-9a-f]{8,32}\.jsonl$/.test(n)); // Claude Code 2.1.2xx names a teammate's file by id and keeps its name in // the `.meta.json` beside it. let named; for (const [name, launch] of teammates) { let fileName = names.find(n => n.startsWith(`agent-a${name}-`)); if (!fileName) { named ??= await claudeChildNames(root, entries); fileName = named.get(name); } const agentId = fileName?.slice("agent-".length, -".jsonl".length); // A named agent launched in the background is already listed by its launch. if (agentId && launches.has(agentId)) continue; const childFile = agentId && await realpath(path.join(root, fileName)).catch(() => undefined); if (!agentId || !childFile || !childFile.startsWith(root + path.sep) || !await claudeChildBelongsTo(childFile, session, agentId)) { if (launch.state === "running") awaiting = true; continue; } // The meta file names the model as the launcher chose it ("sonnet"); // the transcript's first assistant turn carries the full id and wins. // A teammate that never said it was idle or shut down (its session was // restarted or continued) ends the same way: a finished reply, or quiet. if (launch.state === "running") { if (await claudeSkillFinished(childFile).catch(() => false)) launch.state = "completed"; else live = true; } const meta = await readFile(childFile.slice(0, -".jsonl".length) + ".meta.json", "utf8").then(v => object(JSON.parse(v))).catch(() => ({})); const model = await claudeChildModel(childFile).catch(() => undefined) ?? (typeof meta.model === "string" && meta.model ? meta.model.slice(0, 200) : undefined); const checkout = await claudeChildCheckout(childFile).catch(() => ({})); relations.push({ id: createHash("sha256").update(`claude\0${session}\0${agentId}`).digest("hex").slice(0, 32), session: agentId, transcript: childFile, provider: "claude", ...launch, ...(model !== undefined ? { model } : {}), ...checkout, children: [] }); } } if (workflows.size && root) { const seen = new Set(relations.map(relation => relation.session)); for (const workflow of workflows.values()) { for (const child of await claudeWorkflowAgents(root, session, workflow)) { if (seen.has(child.session)) continue; seen.add(child.session); relations.push(child); if (child.state === "running") live = true; } } } claudeRelationCache.set(file, { signature, relations, ...(awaiting || live ? { recheckAt: Date.now() + (awaiting ? 2_000 : 5_000), rows: kept } : {}) }); while (claudeRelationCache.size > 64) claudeRelationCache.delete(claudeRelationCache.keys().next().value); return relations; } /** How long a background skill's transcript may sit unchanged before it is * taken as finished: the parent records no end for it, and a session that * exited mid-skill leaves it without a final reply. */ export const CLAUDE_SKILL_QUIET_MS = 30 * 60 * 1000; /** A background child (a skill, or a sub-agent or teammate whose end the * parent never recorded) is done once its transcript ends on a finished * reply, or has not changed for CLAUDE_SKILL_QUIET_MS. */ async function claudeSkillFinished(file, now = Date.now()) { const metadata = await stat(file); if (now - metadata.mtimeMs >= CLAUDE_SKILL_QUIET_MS) return true; const start = Math.max(0, metadata.size - 65_536), chunks = []; for await (const chunk of createReadStream(file, { start })) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); const last = Buffer.concat(chunks).toString("utf8").split("\n").filter(line => line.trim()).at(-1); try { const raw = object(JSON.parse(last ?? "")); return raw.type === "assistant" && object(raw.message).stop_reason === "end_turn"; } catch { return false; } } /** Sub-agent files by the name their `.meta.json` gives them. */ async function claudeChildNames(root, entries) { const names = new Map(); for (const entry of entries) { if (!/^agent-[A-Za-z0-9._-]{1,128}\.meta\.json$/.test(entry)) continue; const meta = await readFile(path.join(root, entry), "utf8").then(v => object(JSON.parse(v))).catch(() => ({})); if (typeof meta.name === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(meta.name)) names.set(meta.name, entry.slice(0, -".meta.json".length) + ".jsonl"); } return names; } /** A Workflow run's agents from its journal: each `started` agent runs until * the journal records another row for it or the run itself finishes. */ async function claudeWorkflowAgents(root, session, workflow) { const directory = await realpath(path.join(root, "workflows", workflow.runId)).catch(() => undefined); if (!directory || !directory.startsWith(root + path.sep)) return []; const journal = path.join(directory, "journal.jsonl"); const info = await stat(journal).catch(() => undefined); if (!info?.isFile() || info.size > 16 * 1024 * 1024) return []; const agents = new Map(); for (const line of (await readFile(journal, "utf8")).split("\n")) { try { const row = object(JSON.parse(line)), id = String(row.agentId ?? ""); if (!/^[A-Za-z0-9._-]{1,128}$/.test(id)) continue; agents.set(id, row.type === "started" ? agents.get(id) ?? false : true); } catch { /* A row still being written. */ } } const relations = []; for (const [agentId, done] of agents) { const childFile = await realpath(path.join(directory, `agent-${agentId}.jsonl`)).catch(() => undefined); if (!childFile || !childFile.startsWith(directory + path.sep) || !await claudeChildBelongsTo(childFile, session, agentId)) continue; const meta = await readFile(childFile.slice(0, -".jsonl".length) + ".meta.json", "utf8").then(v => object(JSON.parse(v))).catch(() => ({})); const model = await claudeChildModel(childFile).catch(() => undefined); const checkout = await claudeChildCheckout(childFile).catch(() => ({})); relations.push({ id: createHash("sha256").update(`claude\0${session}\0${agentId}`).digest("hex").slice(0, 32), session: agentId, transcript: childFile, provider: "claude", path: String(meta.description || workflow.name).slice(0, 200), callId: workflow.callId || `workflow:${workflow.runId}`, state: done || workflow.state === "completed" ? "completed" : "running", ...(model !== undefined ? { model } : {}), ...checkout, children: [] }); } return relations; } async function claudeChildBelongsTo(file, parent, agentId) { let bytes = Buffer.alloc(0); for await (const chunk of createReadStream(file, { start: 0, end: 1_048_575 })) { bytes = Buffer.concat([bytes, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); const newline = bytes.indexOf(0x0a); if (newline >= 0) { bytes = bytes.subarray(0, newline); break; } } try { const raw = object(JSON.parse(bytes.toString("utf8"))); return (raw.isSidechain === true && raw.sessionId === parent && raw.agentId === agentId) || (raw.type === "fork-context-ref" && raw.parentSessionId === parent && raw.agentId === agentId); } catch { return false; } } // `effort` rides on each assistant row (Claude Code 2.1): with `message.model` // it is what the phone's model chip shows as in effect. `permissionMode` on a // user row is the mode that turn ran in (the phone's permission chip). const CLAUDE_KEYS = new Set(["type", "uuid", "parentUuid", "timestamp", "message", "gitBranch", "cwd", "requestId", "isMeta", "isSidechain", "isCompactSummary", "phrenQueued", "phrenQueueKey", "phrenBackground", "phrenCompacted", "effort", "permissionMode"]); /** The `phrenQueueKey` of the prompt turn a queued message was delivered as: * the same SHA-256 of the unwrapped content the enqueue row exports. Undefined * for a tool result, a harness envelope, or a turn that is not the person's. */ function queuePromptKey(raw) { if (raw.type !== "user" || raw.isSidechain === true) return undefined; const content = object(raw.message).content; const text = unwrapPastedContent(typeof content === "string" ? content : objects(content).filter(block => block.type === "text").map(block => String(block.text ?? "")).join("\n")); if (!text.trim() || text.trimStart().startsWith("<")) return undefined; return createHash("sha256").update(text).digest("hex"); } /** A prompt the agent scheduled for itself, which Claude Code delivers as a * hidden user turn (`isMeta`). Auto-continuation names its origin rather than * a task id. */ function scheduledPromptTurn(raw) { return raw.turnOrigin === "scheduled" || typeof raw.scheduledTaskId === "string" || object(raw.origin).kind === "auto-continuation"; } export const harnessPreamble = (text) => /^<(?:environment_context>|user_instructions>|permission_profile|system-reminder>|turn_context>)/.test(text.trimStart()); function taskNotification(content) { const envelope = /<task-notification>([\s\S]*?)<\/task-notification>/.exec(content)?.[1]; if (!envelope) return; // Only plain tag values are public. Never copy nested output envelopes. const values = new Map(); for (const match of envelope.matchAll(/<([a-z-]+)(?:\s[^<>]*)?>([\s\S]*?)<\/\1>/g)) { if (["task-id", "tool-use-id", "status", "summary"].includes(match[1]) && !match[2].includes("<") && !values.has(match[1])) values.set(match[1], match[2]); } const tags = ["task-id", "tool-use-id", "status", "summary"].flatMap(tag => { const value = values.get(tag); return value === undefined ? [] : [`<${tag}>${value.slice(0, tag === "summary" ? 500 : 200)}</${tag}>`]; }); return tags.some(tag => tag.startsWith("<tool-use-id>")) ? `<task-notification>\n${tags.join("\n")}\n</task-notification>` : undefined; } /** Claude Code files bracketed-paste input — which is how Herdr's * `agent.prompt` delivers every message the phone sends — as * `<pasted_content id="…">…</pasted_content id="…">`. The wrapper is the * terminal's bookkeeping, not what the user wrote. */ const PASTED_CONTENT = /<pasted_content\b[^>]*>\n?([\s\S]*?)\n?<\/pasted_content\b[^>]*>/g; export function unwrapPastedContent(text) { return text.includes("<pasted_content") ? text.replace(PASTED_CONTENT, "$1").trim() : text; } function unwrapUserText(message) { if (message.role !== "user") return message; if (typeof message.content === "string") return { ...message, content: unwrapPastedContent(message.content) }; if (!Array.isArray(message.content)) return message; return { ...message, content: message.content.map(block => { const b = object(block); return b.type === "text" && typeof b.text === "string" ? { ...b, text: unwrapPastedContent(b.text) } : block; }) }; } /** A thinking block Claude marks as narration for the person watching: its * signature is a length-prefixed field reading "narration" (private * reasoning reads "thinking" and is stored without text). */ export function isNarration(block) { if (block.type !== "thinking" || typeof block.thinking !== "string" || !block.thinking.trim()) return false; if (typeof block.signature !== "string" || block.signature.length > 16_384) return false; let bytes; try { bytes = Buffer.from(block.signature, "base64"); } catch { return false; } return bytes.subarray(0, 96).includes(Buffer.from([0x42, 0x09, ...Buffer.from("narration")])); } /** The largest injection the phone is sent; the hook's own budget keeps real ones far smaller. */ const HOOK_CONTEXT_LIMIT = 16_384; const SKILL_BODY = "Base directory for this skill:"; /** What a Skill call loaded. Claude Code answers the call itself with only * "Launching skill: <name>" and writes the skill's text as a hidden user row * pointing back at the call (`sourceToolUseID`). That row alone crosses, as a * second result for the same call, so the phone can show what the skill said; * every other hidden row stays private. */ export function skillBody(raw) { if (raw.type !== "user" || raw.isMeta !== true || raw.isSidechain || typeof raw.sourceToolUseID !== "string" || !raw.sourceToolUseID) return undefined; const content = object(raw.message).content; const text = typeof content === "string" ? content : objects(content).filter(block => block.type === "text").map(block => String(block.text ?? "")).join("\n"); if (!text.startsWith(SKILL_BODY)) return undefined; return { type: "user", timestamp: raw.timestamp, ...(typeof raw.uuid === "string" ? { uuid: raw.uuid } : {}), message: { role: "user", content: [{ type: "tool_result", tool_use_id: raw.sourceToolUseID, content: text.slice(0, 32_768), phrenSkillBody: true }] } }; } /** Claude Code's label where a pasted picture path was, and the footer the * phone puts above the paths of the files it attached. */ const QUEUED_IMAGE_LABEL = /\[Image #\d+\]/; const QUEUED_IMAGE_LABELS = /\[Image #\d+\]/g; const ATTACHED_FILES_FOOTER = /Attached files on this computer:\s*$/; /** A phren UserPromptSubmit injection, as `{ type: "phren_hook_context", * parentUuid, content }`, or undefined for any other row. */ export function phrenHookContext(raw, includeSidechain = false) { if (raw.type !== "attachment" || (raw.isSidechain && !includeSidechain)) return undefined; const attachment = object(raw.attachment); // Input Claude takes mid-turn arrives as a queued_command row right before // its hook output. Only whether it was a background notification crosses, // so the phone can leave a notification's injection out. if (attachment.type === "queued_command") { return { type: "phren_turn_input", timestamp: raw.timestamp, notification: attachment.commandMode === "task-notification" }; } if (attachment.type !== "hook_success" || attachment.hookEvent !== "UserPromptSubmit") return undefined; const content = typeof attachment.content === "string" ? attachment.content : ""; if (!/^\s*◆ phren\b/.test(content) && !content.includes("<phren-context>")) return undefined; return { type: "phren_hook_context", timestamp: raw.timestamp, uuid: raw.uuid, parentUuid: raw.parentUuid, content: content.length > HOOK_CONTEXT_LIMIT ? content.slice(0, HOOK_CONTEXT_LIMIT) + "\n…" : content }; } /** Claude Code rows the phone may see: user, assistant and system turns with * private reasoning redacted, queued phone messages, background task * notifications and compaction markers. */ export function visibleClaudeEvent(raw, includeSidechain = false, queue) { // A follow-up the phone sent a Claude fan-out worker, written into its // event log by the Hook before the resumed run. if (raw.type === "phren/fanout-message" && typeof raw.text === "string") { return { type: "user", timestamp: raw.timestamp, message: { role: "user", content: raw.text.slice(0, 65_536) } }; } // A queued phone message carries the same wrapper; unwrap before the // digest so enqueue and remove keep matching keys. if (raw.type === "queue-operation" && typeof raw.content === "string") raw = { ...raw, content: unwrapPastedContent(raw.content) }; // A dequeue is the queue head handed to a turn. Unlike `remove` it carries // no content, so its consumption is keyed by the prompt turn it delivered // (a newest-first read sees that turn first) and flagged when the agent's // own schedule fired it. if (raw.type === "queue-operation" && raw.operation === "dequeue") { const key = queue?.key, scheduled = queue?.scheduled; // One prompt turn answers one dequeue: an older dequeue must not reuse // this key and mark a different queued message consumed. if (queue) { queue.key = undefined; queue.scheduled = undefined; } return { type: "phren_queue_consumed", ...(key ? { key } : {}), ...(key && scheduled ? { scheduled: true } : {}), timestamp: raw.timestamp }; } // popAll pulls queued prompts back into the input: they were neither // consumed nor scheduled, so the phone must not draw the leftovers as a // "Scheduled check" after the next reply. if (raw.type === "queue-operation" && raw.operation === "popAll" && typeof raw.content === "string") { return { type: "phren_queue_returned", key: createHash("sha256").update(raw.content).digest("hex"), timestamp: raw.timestamp }; } if (raw.type === "queue-operation" && raw.operation === "remove" && typeof raw.content === "string") { return { type: "phren_queue_consumed", key: createHash("sha256").update(raw.content).digest("hex"), timestamp: raw.timestamp }; } // Claude Code records background completion as an internal queue row, // outside the ordinary user/assistant transcript. Export only the small // task-notification envelope; other internal events remain private. if (raw.type === "queue-operation" && typeof raw.content === "string" && raw.content.length <= 65_536 && raw.content.includes("<task-notification>") && raw.content.includes("<tool-use-id>")) { const content = taskNotification(raw.content); return content ? { type: "system", phrenBackground: true, timestamp: raw.timestamp, message: { role: "user", content } } : undefined; } // A message sent while the agent was mid-turn is only ever a queue row: // Claude Code hands it to the model inside a later tool result and never // writes a user turn for it. Export the enqueue as the person's message so // the phone can draw the bubble it sent. Consumption exposes only a digest. if (raw.type === "queue-operation" && ["enqueue", "remove"].includes(String(raw.operation)) && !raw.isMeta && !raw.isSidechain && typeof raw.content === "string" && raw.content.length <= 65_536 && !raw.content.includes("<task-notification>")) { // The paste wrapper was removed above; any envelope still starting // with "<" is the harness's own and stays private. if (raw.content.trimStart().startsWith("<")) return undefined; const text = raw.content; const key = createHash("sha256").update(raw.content).digest("hex"); // Only the identity crosses the wire on consumption: no queue payload, // tool envelope, private metadata, or reasoning is exported. if (raw.operation === "remove") return { type: "phren_queue_consumed", key, timestamp: raw.timestamp }; // A picture sent mid-turn queues as "[Image #7]Attached files on this // computer:": the pixels never reach this row. Words-free, it matched no // receipt, and the phone warned "Not confirmed in chat" about a picture // the agent had taken. Name it the way a picture-only turn reads. const pictureOnly = QUEUED_IMAGE_LABEL.test(text) && !text.replace(QUEUED_IMAGE_LABELS, "").replace(ATTACHED_FILES_FOOTER, "").trim(); return { type: "user", phrenQueued: true, phrenQueueKey: key, timestamp: raw.timestamp, message: { role: "user", content: pictureOnly ? "[Image attachment]" : text } }; } // Claude Code appends a boundary marker and then the summary it hands the // model as a user turn. The phone shows the marker and a bounded preview, // never the full summary as a bubble. if (raw.type === "system" && raw.subtype === "compact_boundary") { return { type: "system", phrenCompacted: true, timestamp: raw.timestamp }; } if (raw.type === "user" && raw.isCompactSummary === true) { const content = object(raw.message).content; return { type: "user", isCompactSummary: true, timestamp: raw.timestamp, message: { role: "user", content: (typeof content === "string" ? content : "").slice(0, 4_000) } }; } // What phren's prompt hook injected into a user turn: Claude Code records // the hook's output as an attachment row pointing at that turn. Only // phren's own output crosses; other hooks' output stays private. const hookContext = phrenHookContext(raw, includeSidechain); if (hookContext) return hookContext; const skill = skillBody(raw); if (skill) return skill; // The row Claude writes when the permission mode changes; nothing else of it. if (raw.type === "permission-mode" && typeof raw.permissionMode === "string" && /^[A-Za-z]{1,30}$/.test(raw.permissionMode)) { return { type: "permission-mode", permissionMode: raw.permissionMode, ...(typeof raw.timestamp === "string" ? { timestamp: raw.timestamp } : {}) }; } // Remember the prompt turn a queued message was delivered as, so the // content-free dequeue written before it can be keyed and flagged scheduled. if (queue && raw.type === "user") { const key = queuePromptKey(raw); if (key !== undefined) { queue.key = key; queue.scheduled = scheduledPromptTurn(raw); } } if (raw.isMeta || (raw.isSidechain && !includeSidechain) || !["user", "assistant", "system"].includes(String(raw.type))) return undefined; raw = Object.fromEntries(Object.entries(raw).filter(([key]) => CLAUDE_KEYS.has(key))); const message = unwrapUserText(object(raw.message)); // Keep indexes for historical images while removing thinking contents. // The one exception is narration: short progress notes the model writes // for the person watching (the lines Claude Code's terminal shows between // tool calls). They arrive as thinking blocks whose signature declares // them narration and whose text is present; private reasoning is stored // with empty text and a "thinking" signature and stays redacted. if (typeof message.content === "string") return raw.type === "user" && harnessPreamble(message.content) ? undefined : { ...raw, message }; if (Array.isArray(message.content)) return { ...raw, message: { ...message, content: objects(message.content).map(b => ["text", "image", "tool_use", "tool_result"].includes(String(b.type)) ? b : isNarration(b) ? { type: "text", text: String(b.thinking), narration: true } : { type: "redacted" }) } }; return undefined; }