/** * SessionStart UX renderer: combines the peer table, the wiring check, and * the pending-council formatter so agent-hook session.started can emit the * combined `systemMessage` JSON directly. * * Outputs a Claude Code SessionStart hookSpecificOutput.additionalContext * string. */ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { resolveBinName, resolveHooksSetupHint } from "../../config.ts"; import { harneryVersion, loadAdapterWiring } from "../../hooks/adapter/wiring.ts"; import { appendEntry } from "../../journal/index.ts"; import { readRemoteMachines } from "../../presence/index.ts"; import { drainMailbox, formatMailboxDelivery, type MailboxMessageV1 } from "../mailbox.ts"; import { readLiveCoordinationRows } from "../state/live-coordination-view.ts"; import { readForkParent } from "../state/names.ts"; import type { AgentActivity, TaskState } from "../state/session-state.ts"; interface HeartbeatRow { instance_id?: string; name?: string; kind?: string; session_id?: string; started_at?: string; last_heartbeat?: string; files_touched?: string[]; platform?: string; task?: string; activity?: AgentActivity; task_state?: TaskState; task_state_reason?: string; } export interface RenderOpts { coordRoot: string; instanceId: string; sessionId: string; agentName?: string; /** Adapter label rendered in parens after the self-name (e.g. "Cursor", "Codex"). * Claude Code omits it. */ platformLabel?: string; } /** * Build the combined SessionStart systemMessage. Returns the additionalContext * string (or "" if there's nothing to say). */ export function renderSessionContext(opts: RenderOpts): string { const { coordRoot, instanceId, sessionId, agentName, platformLabel } = opts; const messages: string[] = []; // 1. Self-name line + peer table (folded if peers present) const peers = readActivePeers(coordRoot, instanceId); const localTable = formatPeerTable(peers, sessionId); // Cross-machine presence (ADR 0016): sessions on other machines (advisory). const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n"); if (agentName) { const suffix = platformLabel ? ` (${platformLabel})` : ""; // The authority clause guards forked/branched chats. An adapter that forks a // conversation copies the parent's transcript, so the fork's context still // asserts the parent's name, claims, and task. When recorded fork lineage // (.name-history forked_from) names the parent, say so specifically — the // fork is about to read a transcript full of that exact name. Otherwise // fall back to the generic clause, which needs no fork detection. const forkParent = readForkParent(coordRoot, instanceId); const authority = forkParent?.name ? `This conversation was branched from agent-${forkParent.name}'s session: earlier context showing agent-${forkParent.name}'s name, task, or file claims belongs to the pre-fork session, not to you.` : forkParent ? `This conversation was branched from another session (${forkParent.instance_id}); any agent name in earlier context belongs to that session, not to you.` : "Any different agent name in earlier context was inherited from another session; this one is authoritative."; const selfLine = `You are agent-${agentName}${suffix}. ${authority}`; messages.push(peerTable ? `${selfLine}\n\n${peerTable}` : selfLine); } else if (peerTable) { messages.push(peerTable); } // 2. Linked-worktree detection if (isLinkedWorktree(coordRoot)) { messages.push( `Running inside worktree ${process.cwd()}. The coord layer is scoped to this worktree only; use \`${resolveBinName(coordRoot)} worktree diff\` to check for conflicts against sibling worktrees.`, ); } // 3. Peer messages queued for this name while no session held it. Draining // here is what makes `agents ping` safe to use on a dormant agent: the sender // never has to know whether the recipient is running. if (agentName) { const delivered = drainMailbox(coordRoot, agentName); if (delivered.length > 0) { recordDeliveredMessages(instanceId, delivered); messages.push(formatMailboxDelivery(coordRoot, delivered)); } } // 4. Council invites if (agentName) { const councilMsg = formatPendingCouncils(coordRoot, agentName); if (councilMsg) messages.push(councilMsg); } // 5. Commit-guard wiring check const wiringIssues = checkWiring(coordRoot); if (wiringIssues.length > 0) { const hint = resolveHooksSetupHint(coordRoot); const fix = hint ? `Run \`${hint}\` to install them.` : `Run \`${resolveBinName(coordRoot)} init\` to install the harnery-managed git-hook regions (commit guard + claim pruning).`; const wiringSummary = `Coordination hooks are NOT wired: the E-guard will not block conflicting commits, and post-commit claim pruning will not run. ${fix} Detected:\n${wiringIssues.map((i) => ` - ${i}`).join("\n")}`; messages.push(wiringSummary); } // 6. Adapter-hook drift: a harnery upgrade changed the hook set, but this // project's settings file hasn't been re-wired. Only fires for a adapter the // project already opted into (≥1 hook wired), so it never nags a project that // simply has a settings file. Remedy is always ` init` (idempotent). const drift = loadAdapterWiring(coordRoot); if (drift.length > 0) { const bin = resolveBinName(coordRoot); const ver = harneryVersion(); const verPart = ver ? ` (harnery ${ver})` : ""; const lines = drift.map((d) => { const bits: string[] = []; if (d.missing.length > 0) bits.push(`missing: ${d.missing.map((m) => m.subcommand).join(", ")}`); if (d.orphans.length > 0) bits.push(`orphaned: ${d.orphans.join(", ")}`); return ` - ${d.settingsFile} — ${bits.join("; ")}`; }); messages.push( `Harnery hook wiring is out of date${verPart}: an upgrade changed the hook set but the adapter ` + `settings file hasn't been re-wired, so the new hook(s) won't fire. Run \`${bin} init\` to wire them ` + `(idempotent, additive).\n${lines.join("\n")}`, ); } return messages.join("\n\n"); } /** * Write delivered peer messages into the recipient's own journal, so they * survive context compaction the way any other coordination breadcrumb does. * A message the sender already journaled (the recipient was live when it was * sent) is skipped to avoid a duplicate entry. */ export function recordDeliveredMessages( instanceId: string, messages: readonly MailboxMessageV1[], ): void { for (const m of messages) { if (m.journaled) continue; try { appendEntry(instanceId, "handoff", `from agent-${m.from_name}: ${m.body}`); } catch { // A journal write failure must never cost the delivery itself; the // message is already in the rendered context the model reads. } } } /** One-line-per-agent view of sessions on other machines (presence refs). */ function formatRemoteMachines(coordRoot: string): string { try { const remote = readRemoteMachines(coordRoot); if (remote.length === 0) return ""; const lines: string[] = []; for (const m of remote) { for (const a of m.agents.slice(0, 10)) { const task = a.task ? ` "${a.task.slice(0, 60)}"` : ""; const files = a.files_touched?.length ? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}` : "nothing held"; const reason = a.task_state === "blocked" && a.task_state_reason ? `: ${a.task_state_reason.slice(0, 80)}` : ""; lines.push( ` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (activity=${a.activity}, lifecycle=${a.task_state}${reason}, ${files})`, ); } } return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`; } catch { return ""; } } /** Read peers from the selected ledger route, excluding self. */ function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRow[] { return readLiveCoordinationRows(coordRoot).filter( (heartbeat) => heartbeat.instance_id && heartbeat.instance_id !== selfInstanceId, ); } /** * Renders the peer table as * two subsections: "Other agent groups active" (blocking) and "Your group" * (subagents/siblings, no mutual block). Folds transient subagents' files * into their parent session. */ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string { if (peers.length === 0) return ""; const nowSec = Math.floor(Date.now() / 1000); // Fold transient peers' files into their session_id parent. const fold: Record = {}; for (const p of peers) { const kind = p.kind ?? "unknown"; if (kind === "transient" && p.session_id) { fold[p.session_id] = (fold[p.session_id] ?? []).concat(p.files_touched ?? []); } } // Build rows with display_files (own files + folded transient files). type RowExt = HeartbeatRow & { display_files: string[] }; const rows: RowExt[] = peers .filter((p) => (p.kind ?? "unknown") !== "transient") .map((p) => { const folded = fold[p.instance_id ?? ""] ?? []; const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort(); return { ...p, display_files: display }; }); const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt); const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt); const sections: string[] = []; const blockingSection = renderSubtable( blocking, "Other agent groups active (their files block you):", nowSec, ); if (blockingSection) sections.push(blockingSection); const groupSection = renderSubtable( group, "Your group (subagents / parent / siblings; no mutual block):", nowSec, ); if (groupSection) sections.push(groupSection); return sections.join("\n\n"); } function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number { return (a.started_at ?? "").localeCompare(b.started_at ?? ""); } function renderSubtable( rows: Array, header: string, nowSec: number, ): string { if (rows.length === 0) return ""; const first = rows.slice(0, 10).map((r) => formatRow(r, nowSec)); const overflow = rows.length > 10 ? `\n +${rows.length - 10} more` : ""; return `${header}\n${first.join("\n")}${overflow}`; } function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string { const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : ""; const activity = r.activity ?? "unknown"; const lifecycle = r.task_state ?? "active"; const reason = lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : ""; const ageFrom = fmtAge(nowSec - parseIsoSec(r.started_at)); const filesPart = fmtFiles(r.display_files); return ` - agent-${r.name ?? "unknown"}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`; } function fmtFiles(files: string[]): string { if (files.length === 0) return "nothing yet"; if (files.length <= 3) return `holds: ${files.join(", ")}`; return `holds: ${files.slice(0, 3).join(", ")}, +${files.length - 3} more`; } function parseIsoSec(iso: string | undefined): number { if (!iso) return 0; const ms = Date.parse(iso); return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0; } function fmtAge(secs: number): string { if (secs < 60) return `${Math.floor(secs)}s ago`; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; return `${Math.floor(secs / 86400)}d ago`; } /** * Returns a list of commit-guard wiring issues (empty when wired). Portable * across host projects: it asserts the FUNCTIONAL property ("does this repo's * pre-commit invoke harnery's guard?") rather than any path convention. For * each repo it resolves the EFFECTIVE git-hooks dir via * `git rev-parse --git-path hooks` (which already honors `core.hooksPath`, * linked worktrees, and submodule gitdirs) and checks whether the `pre-commit` * there calls `agent-coord` / `agent-hook`. Checks the parent repo + one * representative submodule (others almost always share the same setup). * * harnery does not install git hooks itself — each host wires its own * pre-commit to invoke the guard — so the remediation command is host-specific * and supplied via `hooksSetupHint` in `.harnery/config.jsonc` (see the caller). */ export function checkWiring(coordRoot: string): string[] { const issues: string[] = []; if (!preCommitInvokesGuard(coordRoot)) { issues.push( "parent repo: pre-commit hook is missing or doesn't invoke the harnery commit guard", ); } // One representative submodule const gitmodules = join(coordRoot, ".gitmodules"); if (existsSync(gitmodules)) { const sampleSub = extractFirstSubmodule(gitmodules); if (sampleSub) { const subPath = join(coordRoot, sampleSub); if (existsSync(join(subPath, ".git")) && !preCommitInvokesGuard(subPath)) { issues.push( `submodule ${sampleSub}: pre-commit hook doesn't invoke the harnery commit guard (other submodules likely affected too)`, ); } } } return issues; } /** * Whether the repo at `repoDir` has a pre-commit hook — at its effective, * `core.hooksPath`-aware location — that invokes harnery's commit guard. * Fully portable: no assumption about WHERE the host keeps its hooks. */ function preCommitInvokesGuard(repoDir: string): boolean { const hooksDir = gitHooksDir(repoDir); if (!hooksDir) return false; const preCommit = join(hooksDir, "pre-commit"); if (!existsSync(preCommit)) return false; try { return /agent-(coord|hook)\b/.test(readFileSync(preCommit, "utf8")); } catch { return false; } } /** Resolve a repo's effective git-hooks directory (absolute), or null. */ function gitHooksDir(repoDir: string): string | null { const r = spawnSync("git", ["-C", repoDir, "rev-parse", "--git-path", "hooks"], { encoding: "utf8", }); if (r.status !== 0) return null; const p = r.stdout.trim(); if (!p) return null; // `--git-path` prints relative to repoDir (we passed -C); absolutize. return p.startsWith("/") ? p : join(repoDir, p); } function extractFirstSubmodule(gitmodulesPath: string): string | null { try { const content = readFileSync(gitmodulesPath, "utf8"); const match = content.match(/^\s*path\s*=\s*(.+)$/m); return match ? match[1]!.trim() : null; } catch { return null; } } /** * Detect linked-worktree environment (`git worktree` created from the * superproject) via a `git rev-parse --git-dir` vs `--git-common-dir` check. */ function isLinkedWorktree(coordRoot: string): boolean { const dir = spawnSync("git", ["-C", coordRoot, "rev-parse", "--git-dir"], { encoding: "utf8", }); const common = spawnSync("git", ["-C", coordRoot, "rev-parse", "--git-common-dir"], { encoding: "utf8", }); if (dir.status !== 0 || common.status !== 0) return false; const d = dir.stdout.trim(); const c = common.stdout.trim(); return d !== "" && c !== "" && d !== c; } /** * Returns the formatted council invite reminder * (or "" when no councils await input). */ export function formatPendingCouncils(coordRoot: string, agentName: string): string { const councilsDir = join(coordRoot, ".harnery", "councils"); if (!existsSync(councilsDir)) return ""; const canonicalName = agentName.startsWith("agent-") ? agentName : `agent-${agentName}`; const pending: string[] = []; try { for (const f of readdirSync(councilsDir)) { if (!f.endsWith(".json")) continue; const manifestPath = join(councilsDir, f); try { const m = JSON.parse(readFileSync(manifestPath, "utf8")) as { council_id?: string; status?: string; round_status?: string; current_round?: number; members?: string[]; }; if (m.status !== "active" || m.round_status !== "open") continue; if (!m.members?.includes(canonicalName)) continue; const round = m.current_round ?? 1; const contributionPath = join( councilsDir, m.council_id ?? "", `round-${round}`, `${canonicalName}.md`, ); if (existsSync(contributionPath)) continue; // already contributed if (m.council_id) pending.push(m.council_id); } catch { /* skip */ } } } catch { /* skip */ } if (pending.length === 0) return ""; const bin = resolveBinName(coordRoot); const firstThree = pending.slice(0, 3); const tail = pending.length > 3 ? `, +${pending.length - 3} more` : ""; const list = firstThree.join(", ") + tail; if (pending.length === 1) { return ( `Council waiting on your input: \`${pending[0]}\`. ` + `Run \`${bin} agents council show ${pending[0]}\` for the brief and ` + `\`${bin} agents council contribute ${pending[0]} --message ""\` to weigh in.` ); } return `Councils waiting on your input (${pending.length} open): ${list}. Run \`${bin} agents council list --mine\` to see all of them, then \`${bin} agents council show \` for any brief.`; } /** * Suppress unused import warning when statSync isn't used in this file * (kept exported for future renderers, e.g. heartbeat-freshness coloring). */ export const _ensureStatSyncImported = statSync;