feat: initial release of handover plugin for Claude Code

- /handover skill: creates living document preserving context across sessions
- /takeover skill: recovers from forgotten handovers by reading old transcripts
- Statusline: shows context usage % and topic segmentation
- Background daemon uses Haiku for cheap conversation analysis
- Tracks suppressed issues, learnings, dead ends, open questions
- Auto WIP commits to prevent accidental reverts
This commit is contained in:
2026-02-05 11:10:54 +01:00
commit cec8f1ca7d
9 changed files with 841 additions and 0 deletions

178
statusline/ctx_daemon.js Executable file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
"use strict";
const http = require("http");
const fs = require("fs");
const { spawn } = require("child_process");
const path = require("path");
const PORT = 47523;
const CACHE_FILE = path.join(__dirname, "segment_cache.json");
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const MIN_INTERVAL_MS = 60 * 1000; // 60 seconds between analyses
const MIN_PCT_DELTA = 10; // 10% context increase before re-analyze
let cache = loadCache();
let lastRequestTime = Date.now();
let analysisInFlight = false;
function loadCache() {
try {
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
} catch {
return {};
}
}
function saveCache() {
try {
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
} catch (e) {
console.error("Failed to save cache:", e.message);
}
}
function shouldAnalyze(sessionId, pct) {
if (analysisInFlight) return false;
const c = cache[sessionId];
if (!c) return true;
const pctDelta = pct - (c.lastPct || 0);
const timeDelta = Date.now() - (c.lastTime || 0);
return pctDelta >= MIN_PCT_DELTA && timeDelta >= MIN_INTERVAL_MS;
}
function runAnalysis(sessionId, transcriptPath, pct) {
if (analysisInFlight) return;
analysisInFlight = true;
// Read transcript and extract conversation
let transcript = "";
try {
const lines = fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/).filter(Boolean);
const messages = [];
for (const line of lines) {
try {
const j = JSON.parse(line);
const role = j.message?.role;
if (role === "user" || role === "assistant") {
const content = j.message?.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.filter(c => c.type === "text")
.map(c => c.text)
.join(" ");
}
// Truncate very long messages but keep enough context
if (text.length > 500) {
text = text.slice(0, 500) + "...";
}
if (text.trim()) {
const prefix = role === "user" ? "USER" : "ASST";
messages.push(`[${prefix}] ${text.trim()}`);
}
}
} catch {}
}
transcript = messages.join("\n\n");
} catch (e) {
console.error("Failed to read transcript:", e.message);
analysisInFlight = false;
return;
}
const prompt = `Analyze this conversation and identify 3-6 distinct topic segments in chronological order. Be specific about what was discussed - don't merge different topics together.
Output ONLY a single line in this exact format (no explanation):
topic1 XX%|topic2 XX%|topic3 XX%|...|free XX%
Rules:
- 3-6 segments (more segments = more detail, but don't over-split)
- Each topic name is 1-4 words, be specific (e.g. "statusline daemon" not just "code")
- Percentages must sum to 100 and roughly reflect how much of the conversation each topic took
- "free" is remaining unused context = approximately ${100 - Math.round(pct)}%
- Order chronologically (first topic discussed first)
User messages:
${transcript}`;
const child = spawn("claude", ["--print", "-p", prompt], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let stdout = "";
child.stdout.on("data", (d) => stdout += d.toString());
child.stderr.on("data", (d) => console.error("claude stderr:", d.toString()));
child.on("close", (code) => {
analysisInFlight = false;
if (code !== 0) {
console.error("claude exited with code", code);
return;
}
// Parse response: "topic1 XX%|topic2 XX%|free XX%"
const line = stdout.trim().split("\n").pop() || "";
const segments = line.split("|").map(s => {
const match = s.trim().match(/^(.+?)\s+(\d+)%$/);
if (match) return { name: match[1].trim(), pct: parseInt(match[2], 10) };
return null;
}).filter(Boolean);
if (segments.length > 0) {
cache[sessionId] = {
lastPct: pct,
lastTime: Date.now(),
segments,
};
saveCache();
}
});
}
const server = http.createServer((req, res) => {
lastRequestTime = Date.now();
const url = new URL(req.url, `http://localhost:${PORT}`);
if (url.pathname !== "/segments") {
res.writeHead(404);
res.end();
return;
}
const sessionId = url.searchParams.get("session") || "unknown";
const pct = parseFloat(url.searchParams.get("pct") || "0");
const transcriptPath = url.searchParams.get("transcript") || "";
// Check if we should start a new analysis
if (shouldAnalyze(sessionId, pct) && transcriptPath) {
runAnalysis(sessionId, transcriptPath, pct);
}
// Return cached segments (or empty if none yet)
const c = cache[sessionId];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
segments: c?.segments || [],
pending: analysisInFlight,
}));
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`ctx_daemon listening on port ${PORT}`);
});
// Idle timeout check
setInterval(() => {
if (Date.now() - lastRequestTime > IDLE_TIMEOUT_MS) {
console.log("Idle timeout reached, shutting down");
process.exit(0);
}
}, 60000);
// Handle graceful shutdown
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));

147
statusline/ctx_monitor.js Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const http = require("http");
const { spawn } = require("child_process");
const path = require("path");
const input = readJSON(0);
const transcript = input.transcript_path;
const sessionId = input.session_id || "unknown";
const model = input.model?.display_name ?? "Claude";
const CONTEXT_WINDOW = input.context_window?.context_window_size || 200_000;
// Claude provides these directly - use them if available
const providedUsedPct = input.context_window?.used_percentage;
const providedTokens = input.context_window?.total_input_tokens;
const DAEMON_PORT = 47523;
const DAEMON_SCRIPT = path.join(__dirname, "ctx_daemon.js");
function readJSON(fd) {
try { return JSON.parse(fs.readFileSync(fd, "utf8")); } catch { return {}; }
}
function color(p) {
if (p >= 90) return "\x1b[31m"; // red
if (p >= 70) return "\x1b[33m"; // yellow
return "\x1b[32m"; // green
}
function segmentColor(i, isFree) {
if (isFree) return "\x1b[90m"; // gray for free
const colors = ["\x1b[36m", "\x1b[35m", "\x1b[34m", "\x1b[33m"]; // cyan, magenta, blue, yellow
return colors[i % colors.length];
}
function usedTotal(u) {
return (u?.input_tokens ?? 0) + (u?.output_tokens ?? 0) +
(u?.cache_read_input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0);
}
function getUsage() {
if (!transcript) return null;
let lines;
try { lines = fs.readFileSync(transcript, "utf8").split(/\r?\n/); } catch { return null; }
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line) continue;
let j;
try { j = JSON.parse(line); } catch { continue; }
const u = j.message?.usage;
if (j.isSidechain || j.isApiErrorMessage || !u || usedTotal(u) === 0) continue;
if (j.message?.role !== "assistant") continue;
const m = String(j.message?.model ?? "").toLowerCase();
if (m === "<synthetic>" || m.includes("synthetic")) continue;
return u;
}
return null;
}
function startDaemon() {
const child = spawn("node", [DAEMON_SCRIPT], {
detached: true,
stdio: "ignore",
});
child.unref();
}
function fetchSegments(pct, callback) {
const url = `http://127.0.0.1:${DAEMON_PORT}/segments?session=${encodeURIComponent(sessionId)}&pct=${pct}&transcript=${encodeURIComponent(transcript || "")}`;
const req = http.get(url, { timeout: 80 }, (res) => {
let data = "";
res.on("data", (chunk) => data += chunk);
res.on("end", () => {
try {
callback(null, JSON.parse(data));
} catch {
callback(null, { segments: [] });
}
});
});
req.on("error", (e) => {
if (e.code === "ECONNREFUSED") {
startDaemon();
}
callback(null, { segments: [] });
});
req.on("timeout", () => {
req.destroy();
callback(null, { segments: [] });
});
}
function formatSegments(segments) {
if (!segments || segments.length === 0) return "";
const BAR_WIDTH = 20;
// Build the colored bar
let bar = "";
let legend = [];
segments.forEach((s, i) => {
const isFree = s.name.toLowerCase() === "free";
const c = segmentColor(i, isFree);
const width = Math.max(1, Math.round((s.pct / 100) * BAR_WIDTH));
bar += `${c}${"▒".repeat(width)}\x1b[0m`;
// Legend: colored dot + name (truncate only if very long)
const shortName = s.name.length > 15 ? s.name.slice(0, 14) + "…" : s.name;
legend.push(`${c}${shortName}\x1b[0m`);
});
// Pad bar to fixed width if needed
const barLen = segments.reduce((sum, s) => sum + Math.max(1, Math.round((s.pct / 100) * BAR_WIDTH)), 0);
if (barLen < BAR_WIDTH) {
bar += "\x1b[90m░\x1b[0m".repeat(BAR_WIDTH - barLen);
}
return ` ${bar} ${legend.join(" ")}`;
}
// Main
const usage = getUsage();
if (!usage) {
// No usage yet - show 0% and note it's a fresh session
console.log(`\x1b[95m${model}\x1b[0m \x1b[90m│\x1b[0m \x1b[32m0% used\x1b[0m \x1b[90m(new session)\x1b[0m`);
process.exit(0);
}
// Prefer Claude's provided percentage, fall back to calculating from usage
const pct = providedUsedPct ?? Math.round((usedTotal(usage) * 1000) / CONTEXT_WINDOW) / 10;
// Calculate total tokens as Claude does: input + output roughly
const totalTokens = (input.context_window?.total_input_tokens || 0) + (input.context_window?.total_output_tokens || 0);
// But actually use percentage * context_window for display to match /context
const usedTokens = Math.round((pct / 100) * CONTEXT_WINDOW);
const k = (n) => n >= 1000 ? (n / 1000).toFixed(0) + "k" : n;
// Try to get segments from daemon (with tiny timeout)
fetchSegments(pct, (err, result) => {
const segmentStr = formatSegments(result?.segments);
console.log(`\x1b[95m${model}\x1b[0m \x1b[90m│\x1b[0m ${color(pct)}${pct.toFixed(1)}% used\x1b[0m \x1b[90m(${k(usedTokens)})\x1b[0m${segmentStr}`);
});