diff --git a/kanban-artifacts/README.md b/kanban-artifacts/README.md
new file mode 100644
index 0000000..b76a204
--- /dev/null
+++ b/kanban-artifacts/README.md
@@ -0,0 +1,47 @@
+# kanban-artifacts
+
+Dashboard plugin for browsing and previewing output files from Hermes Kanban task workspaces.
+
+## What it does
+
+Adds a **Kanban Artifacts** tab to the Hermes dashboard (positioned after the built-in Kanban tab). Users can:
+
+- Browse boards and tasks from the Kanban system
+- Explore artifact files in each task's workspace (`~/.hermes/kanban/workspaces/`)
+- Preview text, code, markdown, images, and other common file types inline
+- Download raw files with streaming support (50 MB cap)
+
+## Security
+
+- All API routes require a valid session token (`X-Hermes-Session-Token` or `Authorization: Bearer`)
+- Path traversal is blocked — only `~/.hermes/kanban/workspaces/` and `~/.hermes/kanban/boards/` are accessible
+- Files larger than 50 MB cannot be served via `/raw`
+- SQL injection is prevented with parameterized queries
+- Markdown content is HTML-escaped before rendering to prevent XSS
+
+## File structure
+
+```
+kanban-artifacts/dashboard/
+ manifest.json — Dashboard plugin manifest (tab, icon, entry point, API file)
+ plugin_api.py — FastAPI backend (list boards/tasks/files, serve raw files)
+ index.js — React frontend source
+ dist/
+ index.js — Built frontend bundle
+ style.css — Styles
+```
+
+## Installing
+
+Copy the `kanban-artifacts/` directory to:
+
+```
+~/.hermes/plugins/kanban-artifacts/dashboard/
+```
+
+The plugin is auto-discovered by the Hermes dashboard on next startup.
+
+## Reference
+
+- [Hermes Plugin Proposal #8994](https://github.com/NousResearch/hermes-agent/issues/8994)
+- Tracking issue: [Proposal: kanban-artifacts #32473](https://github.com/NousResearch/hermes-agent/issues/32473)
\ No newline at end of file
diff --git a/kanban-artifacts/dashboard/dist/index.js b/kanban-artifacts/dashboard/dist/index.js
new file mode 100644
index 0000000..2d03316
--- /dev/null
+++ b/kanban-artifacts/dashboard/dist/index.js
@@ -0,0 +1 @@
+(()=>{(function(){"use strict";let p=window.__HERMES_PLUGIN_SDK__;if(!p)return;let{React:F}=p,e=F.createElement,{Card:fe,CardContent:ge,Badge:O,Button:M,Input:P,Label:xe,Select:he,SelectOption:be,Separator:ve,Tabs:ye,TabsList:we,TabsTrigger:Ne}=p.components,{useState:c,useEffect:v,useCallback:ke,useMemo:Q,useRef:$e}=p.hooks,{cn:x,timeAgo:Ce}=p.utils,J=p.useI18n||function(){return{t:{kanbanArtifacts:null},locale:"en"}};function k(s,n,o){let r=s&&s.kanbanArtifacts;if(r){let u=n.split(".");for(let i=0;i/g,">").replace(/"/g,""")}function $(s){return s<1024?s+" B":s<1024*1024?(s/1024).toFixed(1)+" KB":s<1024*1024*1024?(s/(1024*1024)).toFixed(1)+" MB":(s/(1024*1024*1024)).toFixed(2)+" GB"}function V(s){return{todo:"bg-gray-400",in_progress:"bg-blue-500",running:"bg-yellow-500",done:"bg-green-500",completed:"bg-green-500",blocked:"bg-red-500"}[s]||"bg-gray-400"}function C(s){let n=s.split(".").pop().toLowerCase();return{md:"\u{1F4DD}",markdown:"\u{1F4DD}",txt:"\u{1F4C4}",log:"\u{1F4C4}",py:"\u{1F40D}",js:"\u{1F7E8}",ts:"\u{1F537}",jsx:"\u269B\uFE0F",tsx:"\u269B\uFE0F",html:"\u{1F310}",css:"\u{1F3A8}",scss:"\u{1F3A8}",json:"\u{1F4CB}",yaml:"\u2699\uFE0F",yml:"\u2699\uFE0F",toml:"\u2699\uFE0F",png:"\u{1F5BC}\uFE0F",jpg:"\u{1F5BC}\uFE0F",jpeg:"\u{1F5BC}\uFE0F",gif:"\u{1F5BC}\uFE0F",webp:"\u{1F5BC}\uFE0F",svg:"\u{1F5BC}\uFE0F",pdf:"\u{1F4D5}",zip:"\u{1F5DC}\uFE0F",tar:"\u{1F5DC}\uFE0F",gz:"\u{1F5DC}\uFE0F",sh:"\u{1F4DF}",bash:"\u{1F4DF}",sql:"\u{1F5C3}\uFE0F",csv:"\u{1F4CA}"}[n]||"\u{1F4CE}"}let Y=new Set(["py","js","ts","jsx","tsx","html","css","scss","sass","json","yaml","yml","toml","xml","sql","sh","bash","zsh","fish","r","lua","pl","rb","go","rs","c","cpp","h","hpp","java","kt","swift","proto","graphql","gql","md","markdown","txt","log","env"]),Z={py:"python",js:"javascript",ts:"typescript",jsx:"javascript",tsx:"typescript",html:"html",css:"css",scss:"scss",json:"json",yaml:"yaml",yml:"yaml",xml:"xml",sql:"sql",sh:"bash",bash:"bash",md:"markdown",txt:"plaintext",r:"r",rb:"ruby",rs:"rust",go:"go",c:"c",cpp:"cpp",h:"c",hpp:"cpp",java:"java",kt:"kotlin",swift:"swift",proto:"protobuf",graphql:"graphql"};function j({className:s}){return e("div",{className:x("animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent",s)})}function ee({file:s,active:n,onClick:o}){return e("div",{className:x("flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md text-sm transition-colors",n?"bg-blue-500/20 text-blue-400":"hover:bg-white/5 text-gray-200"),onClick:o},e("span",{className:"text-base"},C(s.name)),e("span",{className:"flex-1 truncate"},s.name),e("span",{className:"text-gray-500 text-xs shrink-0"},$(s.size)))}function te(){let{t:s}=J(),[n,o]=c([]),[r,u]=c([]),[i,T]=c({}),[Me,De]=c({}),[h,B]=c(null),[l,ae]=c(null),[d,b]=c(null),[I,f]=c(null),[re,R]=c(!0),[le,S]=c(!1),[oe,_]=c(!1),[w,ce]=c(""),[D,E]=c(null),[q,A]=c({}),[L,ie]=c({board:280,file:220}),[Se,U]=c(null),g=F.useRef(null),H=t=>{if(!g.current)return;t.preventDefault();let a=g.current.startX,m=g.current.startWidths,N=t.clientX-a,z={...m};g.current.side==="board-file"?z.board=Math.max(160,m.board+N):z.file=Math.max(140,m.file+N),ie(z)},K=()=>{U(null),g.current=null,document.body.style.userSelect="",document.body.style.cursor="",document.removeEventListener("mousemove",H),document.removeEventListener("mouseup",K)},W=(t,a)=>{a.preventDefault(),g.current={startX:a.clientX,startWidths:{...L},side:t},U(t),document.body.style.userSelect="none",document.body.style.cursor="col-resize",document.addEventListener("mousemove",H),document.addEventListener("mouseup",K)};v(()=>{y("/boards").then(t=>{if(o(t),R(!1),t.length>0&&!h){let a=t[0].slug;B(a),A({[a]:!0})}}).catch(()=>R(!1))},[]),v(()=>{if(!h)return;if(Me[h]){u(Me[h]);return;}y(`/boards/${h}/tasks`).then(data=>{De(prev=>({...prev,[h]:data}));u(data)}).catch(()=>u([]))},[h]),v(()=>{if(!l){b(null),f(null);return}if(i[l.id]!==void 0){b(null),f(null);return}S(!0),b(null),f(null);let a=l.workspace_path?`/tasks/${l.id}/files?path=${encodeURIComponent(l.workspace_path)}`:`/tasks/${l.id}/files`;y(a).then(m=>{T(N=>({...N,[l.id]:m})),S(!1)}).catch(()=>{T(m=>({...m,[l.id]:[]})),S(!1)})},[l]),v(()=>{if(!d){f(null),E(null);return}_(!0),E(null),y(`/files?path=${encodeURIComponent(d.path)}`).then(t=>{f(t),_(!1)}).catch(t=>{E(String(t)),_(!1)})},[d]);function ue(t){A(a=>({...a,[t]:!a[t]})),B(t)}function de(t){ae(t),b(null),f(null)}function me(t){b(t)}let X=Q(()=>{if(!w)return r;let t=w.toLowerCase();return r.filter(a=>(a.title||"").toLowerCase().includes(t)||(a.id||"").toLowerCase().includes(t))},[r,w]),G=l?i[l.id]||[]:[],pe=l?e("div",{className:"flex items-center gap-2 text-xs text-gray-400 mt-1"},e(O,{variant:l.status==="done"?"success":"secondary",className:"text-xs"},l.status),e("span",null,l.assignee||"unassigned")):null;return e("div",{className:"flex h-full overflow-hidden"},e("div",{className:"shrink-0 border-r border-white/10 flex flex-col overflow-hidden",style:{width:L.board}},e("div",{className:"p-3 border-b border-white/10"},e("div",{className:"font-semibold text-sm text-gray-200 mb-2"},k(s,"title","Kanban Artifacts")),e(P,{placeholder:k(s,"filter","Filter tasks\u2026"),value:w,onChange:t=>ce(t.target.value),className:"h-8 text-xs"})),e("div",{className:"flex-1 overflow-y-auto p-2"},re?e("div",{className:"flex justify-center py-8"},e(j)):n.length===0?e("p",{className:"text-xs text-gray-500 px-2"},"No boards found"):n.map(t=>e("div",{key:t.slug,className:"mb-1"},e("div",{className:x("flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-semibold cursor-pointer","text-gray-400 hover:text-gray-200 select-none transition-colors",h===t.slug&&"text-gray-200"),onClick:()=>ue(t.slug)},e("span",{className:"text-gray-500 transition-transform",style:{transform:q[t.slug]?"rotate(90deg)":"none"}},"\u25B6"),e("span",null,t.slug)),q[t.slug]&&e("div",{className:"mt-1"},(Me[t.slug]||[]).filter(a=>(a.title||"").toLowerCase().includes(w)||(a.id||"").toLowerCase().includes(w)).length===0?e("p",{className:"text-xs text-gray-600 px-2 py-1"},"No tasks"):(Me[t.slug]||[]).filter(a=>(a.title||"").toLowerCase().includes(w)||(a.id||"").toLowerCase().includes(w)).map(a=>e("div",{key:a.id,className:x("flex items-start gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors","hover:bg-white/5",l&&l.id===a.id&&"bg-blue-500/15"),onClick:()=>de(a)},e("span",{className:x("w-2 h-2 rounded-full mt-0.5 shrink-0",V(a.status))}),e("div",{className:"flex-1 min-w-0"},e("div",{className:"text-xs text-gray-300 truncate"},a.title||"(untitled)"),e("div",{className:"text-gray-600 text-xs truncate"},a.id))))))))),e("div",{className:"shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",style:{width:6},onMouseDown:t=>W("board-file",t),onClick:t=>t.preventDefault()},e("div",{className:"w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors"}),e("div",{className:"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",style:{pointerEvents:"none"}},e("div",{className:"w-0.5 h-3 rounded-full bg-black/30"}))),e("div",{className:"shrink-0 border-r border-white/10 flex flex-col overflow-hidden",style:{width:L.file}},e("div",{className:"p-3 border-b border-white/10"},l?e("div",null,e("div",{className:"text-sm font-medium text-gray-200 truncate"},l.title||"(untitled)"),pe):e("div",{className:"text-xs text-gray-500"},k(s,"selectTask","Select a task"))),e("div",{className:"flex-1 overflow-y-auto p-1"},l?le?e("div",{className:"flex justify-center py-8"},e(j)):G.length===0?e("p",{className:"text-xs text-gray-600 text-center py-8"},"No files"):G.map(t=>e(ee,{key:t.path,file:t,active:d&&d.path===t.path,onClick:()=>me(t)})):e("p",{className:"text-xs text-gray-600 text-center py-8"},"\u2190 Select a task"))),e("div",{className:"shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",style:{width:6},onMouseDown:t=>W("file-preview",t),onClick:t=>t.preventDefault()},e("div",{className:"w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors"}),e("div",{className:"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",style:{pointerEvents:"none"}},e("div",{className:"w-0.5 h-3 rounded-full bg-black/30"}))),e("div",{className:"flex-1 flex flex-col overflow-hidden"},d&&e("div",{className:"flex items-center justify-between px-4 py-2 border-b border-white/10 bg-black/20"},e("div",{className:"flex items-center gap-2 min-w-0"},e("span",{className:"text-sm"},C(d.name)),e("span",{className:"text-sm text-gray-200 truncate"},d.name)),e("div",{className:"flex gap-2 shrink-0"},e(M,{variant:"ghost",size:"sm",className:"text-xs h-7",onClick:()=>{let t=document.createElement("a");t.href=`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(d.path)}`,t.download=d.name,t.click()}},"\u2B07 Download"))),e("div",{className:"flex-1 overflow-y-auto"},d?oe?e("div",{className:"flex justify-center items-center h-full"},e(j)):D?e("div",{className:"text-red-400 text-sm p-4"},"Error: "+D):I&&se(I,d):e("div",{className:"flex items-center justify-center h-full text-gray-500 text-sm"},"\u2190 Select a file to preview"))))}function se(s,n){let o=n.name.split(".").pop().toLowerCase(),r=["md","markdown"].includes(o),u=Y.has(o);if(s.mime&&s.mime.startsWith("image/"))return e("div",{className:"p-4 flex justify-center"},e("img",{src:`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(n.path)}`,alt:n.name,className:"max-w-full rounded-lg border border-white/10"}));if(s.text!==void 0&&s.text!==null){let i=s.truncated?e("div",{className:"text-yellow-500 text-xs px-4 pt-3"},`\u26A0 File truncated (${$(s.size)}). Download to view full content.`):null;return r?e("div",{className:"p-4 max-w-3xl"},e("div",{className:"prose prose-invert prose-sm max-w-none",dangerouslySetInnerHTML:{__html:ne(s.text)}}),i):u?e("div",{className:"p-4"},e("pre",{className:"bg-gray-900 rounded-lg border border-white/10 p-4 overflow-x-auto text-sm"},e("code",{className:`language-${Z[o]||o}`},s.text)),i):e("div",{className:"p-4"},e("pre",{className:"text-sm text-gray-300 whitespace-pre-wrap break-all font-mono"},s.text),i)}return e("div",{className:"flex flex-col items-center justify-center h-full gap-3 text-gray-400"},e("div",{className:"text-5xl"},C(n.name)),e("div",{className:"text-sm"},n.name),e("div",{className:"text-xs text-gray-600"},`${$(s.size)} \xB7 ${s.mime}`),e(M,{variant:"outline",size:"sm",className:"mt-2",onClick:()=>{let i=document.createElement("a");i.href=`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(n.path)}`,i.download=n.name,i.click()}},"\u2B07 Download"))}function ne(s){return s?s.replace(/&/g,"&").replace(//g,">").replace(/```(\w*)\n([\s\S]*?)```/g,(n,o,r)=>`${r.replace(/\n/g,"
")}
`).replace(/`([^`]+)`/g,"$1").replace(/^### (.+)$/gm,"$1
").replace(/^## (.+)$/gm,"$1
").replace(/^# (.+)$/gm,"$1
").replace(/\*\*([^*]+)\*\*/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g,'$1').replace(/^> (.+)$/gm,'$1
').replace(/^---$/gm,"
").replace(/^[-*] (.+)$/gm,"$1").replace(/^\d+\. (.+)$/gm,"$1").replace(/(.*<\/li>\n?)+/g,n=>``).replace(/\n\n([^<])/g,"
$1").replace(/\n/g,"
"):""}window.__HERMES_PLUGINS__.register("kanban-artifacts",te)})();})();
diff --git a/kanban-artifacts/dashboard/dist/style.css b/kanban-artifacts/dashboard/dist/style.css
new file mode 100644
index 0000000..b6c8510
--- /dev/null
+++ b/kanban-artifacts/dashboard/dist/style.css
@@ -0,0 +1,9 @@
+/* Kanban Artifacts — dashboard plugin styles */
+
+:root {
+ --plugin-accent: #3b82f6;
+}
+
+[data-plugin="kanban-artifacts"] {
+ font-family: inherit;
+}
\ No newline at end of file
diff --git a/kanban-artifacts/dashboard/index.js b/kanban-artifacts/dashboard/index.js
new file mode 100644
index 0000000..f7dd2e7
--- /dev/null
+++ b/kanban-artifacts/dashboard/index.js
@@ -0,0 +1,569 @@
+/**
+ * Hermes Kanban Artifact Viewer — Dashboard Plugin
+ *
+ * Plain IIFE using window.__HERMES_PLUGIN_SDK__ for React + shadcn primitives.
+ * Registers at /kanban-artifacts tab.
+ *
+ * SDK:
+ * window.__HERMES_PLUGINS__.register(name, Component)
+ * window.__HERMES_PLUGIN_SDK__.api.fetchJSON(path) → /api/plugins/kanban-artifacts/
+ * window.__HERMES_PLUGIN_SDK__.components.* (Card, Badge, Button, Tabs, etc.)
+ * window.__HERMES_PLUGIN_SDK__.hooks.* (useState, useEffect, useCallback, useMemo)
+ * window.__HERMES_PLUGIN_SDK__.utils.* (cn, timeAgo, isoTimeAgo)
+ */
+
+(function () {
+ "use strict";
+
+ const SDK = window.__HERMES_PLUGIN_SDK__;
+ if (!SDK) return;
+
+ const { React } = SDK;
+ const h = React.createElement;
+
+ const {
+ Card, CardContent,
+ Badge, Button, Input,
+ Label, Select, SelectOption,
+ Separator, Tabs, TabsList, TabsTrigger,
+ } = SDK.components;
+
+ const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks;
+ const { cn, timeAgo } = SDK.utils;
+
+ const useI18n = SDK.useI18n || function () {
+ return { t: { kanbanArtifacts: null }, locale: "en" };
+ };
+
+ function tx(t, path, fallback) {
+ let node = t && t.kanbanArtifacts;
+ if (node) {
+ const parts = path.split(".");
+ for (let i = 0; i < parts.length; i++) {
+ if (node && typeof node === "object" && parts[i] in node) {
+ node = node[parts[i]];
+ } else { node = null; break; }
+ }
+ }
+ return (typeof node === "string") ? node : fallback;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ async function apiFetch(path) {
+ // Plugin routes live under /api/plugins//
+ // The session token lives in window.__HERMES_SESSION_TOKEN__ (set by the dashboard SPA).
+ // We can't use credentials: 'include' with a cookie that doesn't exist — instead
+ // we forward the token explicitly as an Authorization header.
+ const url = `/api/plugins/kanban-artifacts${path}`;
+ const headers = { "Content-Type": "application/json" };
+ const token = window.__HERMES_SESSION_TOKEN__;
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+ const resp = await fetch(url, { credentials: "include", headers });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ return resp.json();
+ }
+
+ function escHtml(s) {
+ if (s === null || s === undefined) return "";
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function formatSize(bytes) {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+ return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
+ }
+
+ function statusDotClass(status) {
+ const map = {
+ "todo": "bg-gray-400",
+ "in_progress": "bg-blue-500",
+ "running": "bg-yellow-500",
+ "done": "bg-green-500",
+ "completed": "bg-green-500",
+ "blocked": "bg-red-500",
+ };
+ return map[status] || "bg-gray-400";
+ }
+
+ function fileIcon(name) {
+ const ext = name.split(".").pop().toLowerCase();
+ const icons = {
+ md: "📝", markdown: "📝", txt: "📄", log: "📄",
+ py: "🐍", js: "🟨", ts: "🔷", jsx: "⚛️", tsx: "⚛️",
+ html: "🌐", css: "🎨", scss: "🎨",
+ json: "📋", yaml: "⚙️", yml: "⚙️", toml: "⚙️",
+ png: "🖼️", jpg: "🖼️", jpeg: "🖼️", gif: "🖼️", webp: "🖼️", svg: "🖼️",
+ pdf: "📕",
+ zip: "🗜️", tar: "🗜️", gz: "🗜️",
+ sh: "📟", bash: "📟",
+ sql: "🗃️", csv: "📊",
+ };
+ return icons[ext] || "📎";
+ }
+
+ const CODE_EXTS = new Set([
+ "py", "js", "ts", "jsx", "tsx", "html", "css", "scss", "sass",
+ "json", "yaml", "yml", "toml", "xml", "sql", "sh", "bash",
+ "zsh", "fish", "r", "lua", "pl", "rb", "go", "rs", "c", "cpp",
+ "h", "hpp", "java", "kt", "swift", "proto", "graphql", "gql",
+ "md", "markdown", "txt", "log", "env",
+ ]);
+
+ const LANG_MAP = {
+ py: "python", js: "javascript", ts: "typescript",
+ jsx: "javascript", tsx: "typescript",
+ html: "html", css: "css", scss: "scss",
+ json: "json", yaml: "yaml", yml: "yaml",
+ xml: "xml", sql: "sql", sh: "bash", bash: "bash",
+ md: "markdown", txt: "plaintext", r: "r",
+ rb: "ruby", rs: "rust", go: "go",
+ c: "c", cpp: "cpp", h: "c", hpp: "cpp",
+ java: "java", kt: "kotlin", swift: "swift",
+ proto: "protobuf", graphql: "graphql",
+ };
+
+ // ── Components ────────────────────────────────────────────────────────────────
+
+ function Spinner({ className }) {
+ return h("div", { className: cn("animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent", className) });
+ }
+
+ function FileItem({ file, active, onClick }) {
+ return h("div", {
+ className: cn(
+ "flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md text-sm transition-colors",
+ active ? "bg-blue-500/20 text-blue-400" : "hover:bg-white/5 text-gray-200"
+ ),
+ onClick,
+ },
+ h("span", { className: "text-base" }, fileIcon(file.name)),
+ h("span", { className: "flex-1 truncate" }, file.name),
+ h("span", { className: "text-gray-500 text-xs shrink-0" }, formatSize(file.size))
+ );
+ }
+
+ // ── Main App ─────────────────────────────────────────────────────────────────
+
+ function KanbanArtifactsApp() {
+ const { t } = useI18n();
+
+ const [boards, setBoards] = useState([]);
+ const [tasks, setTasks] = useState([]);
+ const [filesCache, setFilesCache] = useState({});
+ const [tasksCache, setTasksCache] = useState({});
+ const [currentBoard, setCurrentBoard] = useState(null);
+ const [currentTask, setCurrentTask] = useState(null);
+ const [currentFile, setCurrentFile] = useState(null);
+ const [fileContent, setFileContent] = useState(null);
+ const [loadingBoards, setLoadingBoards] = useState(true);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const [loadingFile, setLoadingFile] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [fileError, setFileError] = useState(null);
+ const [expandedBoards, setExpandedBoards] = useState({});
+
+ // Column resizing
+ const [colWidths, setColWidths] = useState({ board: 280, file: 220 });
+ const [resizing, setResizing] = useState(null); // 'board' | 'file'
+ const resizingRef = React.useRef(null);
+
+ const onMouseMove = (e) => {
+ if (!resizingRef.current) return;
+ e.preventDefault();
+ const startX = resizingRef.current.startX;
+ const startWidths = resizingRef.current.startWidths;
+ const delta = e.clientX - startX;
+ const next = { ...startWidths };
+ if (resizingRef.current.side === 'board-file') {
+ next.board = Math.max(160, startWidths.board + delta);
+ } else {
+ next.file = Math.max(140, startWidths.file + delta);
+ }
+ setColWidths(next);
+ };
+ const onMouseUp = () => {
+ setResizing(null);
+ resizingRef.current = null;
+ document.body.style.userSelect = '';
+ document.body.style.cursor = '';
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ };
+ const startResize = (side, e) => {
+ e.preventDefault();
+ resizingRef.current = { startX: e.clientX, startWidths: { ...colWidths }, side };
+ setResizing(side);
+ document.body.style.userSelect = 'none';
+ document.body.style.cursor = 'col-resize';
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ };
+
+ // Load boards on mount
+ useEffect(() => {
+ apiFetch("/boards").then(b => {
+ setBoards(b);
+ setLoadingBoards(false);
+ if (b.length > 0 && !currentBoard) {
+ const first = b[0].slug;
+ setCurrentBoard(first);
+ setExpandedBoards({ [first]: true });
+ }
+ }).catch(() => setLoadingBoards(false));
+ }, []);
+
+ // Load tasks when board changes
+ useEffect(() => {
+ if (!currentBoard) return;
+ if (tasksCache[currentBoard]) { setTasks(tasksCache[currentBoard]); return; }
+ apiFetch(`/boards/${currentBoard}/tasks`).then(data => {
+ setTasksCache(prev => ({ ...prev, [currentBoard]: data }));
+ setTasks(data);
+ }).catch(() => setTasks([]));
+ }, [currentBoard]);
+
+ // Load files when task changes
+ useEffect(() => {
+ if (!currentTask) { setCurrentFile(null); setFileContent(null); return; }
+ const cached = filesCache[currentTask.id];
+ if (cached !== undefined) { setCurrentFile(null); setFileContent(null); return; }
+
+ setLoadingFiles(true);
+ setCurrentFile(null);
+ setFileContent(null);
+
+ const url = currentTask.workspace_path
+ ? `/tasks/${currentTask.id}/files?path=${encodeURIComponent(currentTask.workspace_path)}`
+ : `/tasks/${currentTask.id}/files`;
+
+ apiFetch(url).then(files => {
+ setFilesCache(prev => ({ ...prev, [currentTask.id]: files }));
+ setLoadingFiles(false);
+ }).catch(() => { setFilesCache(prev => ({ ...prev, [currentTask.id]: [] })); setLoadingFiles(false); });
+ }, [currentTask]);
+
+ // Load file content when file changes
+ useEffect(() => {
+ if (!currentFile) { setFileContent(null); setFileError(null); return; }
+ setLoadingFile(true);
+ setFileError(null);
+ apiFetch(`/files?path=${encodeURIComponent(currentFile.path)}`)
+ .then(data => { setFileContent(data); setLoadingFile(false); })
+ .catch(err => { setFileError(String(err)); setLoadingFile(false); });
+ }, [currentFile]);
+
+ function toggleBoard(slug) {
+ setExpandedBoards(prev => ({ ...prev, [slug]: !prev[slug] }));
+ setCurrentBoard(slug);
+ }
+
+ function selectTask(task) {
+ setCurrentTask(task);
+ setCurrentFile(null);
+ setFileContent(null);
+ }
+
+ function selectFile(file) {
+ setCurrentFile(file);
+ }
+
+ const filteredTasks = useMemo(() => {
+ if (!searchQuery) return tasks;
+ const q = searchQuery.toLowerCase();
+ return tasks.filter(t =>
+ (t.title || "").toLowerCase().includes(q) ||
+ (t.id || "").toLowerCase().includes(q)
+ );
+ }, [tasks, searchQuery]);
+
+ const files = currentTask ? (filesCache[currentTask.id] || []) : [];
+
+ const taskMeta = currentTask ? h("div", { className: "flex items-center gap-2 text-xs text-gray-400 mt-1" },
+ h(Badge, { variant: currentTask.status === "done" ? "success" : "secondary", className: "text-xs" }, currentTask.status),
+ h("span", null, currentTask.assignee || "unassigned")
+ ) : null;
+
+ return h("div", { className: "flex h-full overflow-hidden" },
+
+ // ── Sidebar: boards + tasks ─────────────────────────────────────────────
+ h("div", { className: "shrink-0 border-r border-white/10 flex flex-col overflow-hidden", style: { width: colWidths.board } },
+ h("div", { className: "p-3 border-b border-white/10" },
+ h("div", { className: "font-semibold text-sm text-gray-200 mb-2" }, tx(t, "title", "Kanban Artifacts")),
+ h(Input, {
+ placeholder: tx(t, "filter", "Filter tasks…"),
+ value: searchQuery,
+ onChange: e => setSearchQuery(e.target.value),
+ className: "h-8 text-xs",
+ })
+ ),
+
+ h("div", { className: "flex-1 overflow-y-auto p-2" },
+ loadingBoards
+ ? h("div", { className: "flex justify-center py-8" }, h(Spinner))
+ : boards.length === 0
+ ? h("p", { className: "text-xs text-gray-500 px-2" }, "No boards found")
+ : boards.map(board =>
+ h("div", { key: board.slug, className: "mb-1" },
+ h("div", {
+ className: cn(
+ "flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-semibold cursor-pointer",
+ "text-gray-400 hover:text-gray-200 select-none transition-colors",
+ currentBoard === board.slug && "text-gray-200"
+ ),
+ onClick: () => toggleBoard(board.slug)
+ },
+ h("span", { className: "text-gray-500 transition-transform", style: { transform: expandedBoards[board.slug] ? "rotate(90deg)" : "none" } }, "▶"),
+ h("span", null, board.slug)
+ ),
+ expandedBoards[board.slug] && h("div", { className: "mt-1" },
+ filteredTasks.length === 0
+ ? h("p", { className: "text-xs text-gray-600 px-2 py-1" }, "No tasks")
+ : filteredTasks.map(task =>
+ h("div", {
+ key: task.id,
+ className: cn(
+ "flex items-start gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors",
+ "hover:bg-white/5",
+ currentTask && currentTask.id === task.id && "bg-blue-500/15"
+ ),
+ onClick: () => selectTask(task)
+ },
+ h("span", {
+ className: cn("w-2 h-2 rounded-full mt-0.5 shrink-0", statusDotClass(task.status))
+ }),
+ h("div", { className: "flex-1 min-w-0" },
+ h("div", { className: "text-xs text-gray-300 truncate" }, task.title || "(untitled)"),
+ h("div", { className: "text-gray-600 text-xs truncate" }, task.id)
+ )
+ )
+ )
+ )
+ )
+ )
+ )
+ ),
+
+ // ── Resize handle: board ↔ file ─────────────────────────────────────────
+ h("div", {
+ className: "shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",
+ style: { width: 6 },
+ onMouseDown: e => startResize('board-file', e),
+ onClick: e => e.preventDefault(),
+ },
+ h("div", { className: "w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors" }),
+ h("div", {
+ className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",
+ style: { pointerEvents: 'none' },
+ },
+ h("div", { className: "w-0.5 h-3 rounded-full bg-black/30" })
+ )
+ ),
+
+ // ── File list ──────────────────────────────────────────────────────────
+ h("div", { className: "shrink-0 border-r border-white/10 flex flex-col overflow-hidden", style: { width: colWidths.file } },
+ h("div", { className: "p-3 border-b border-white/10" },
+ currentTask
+ ? h("div", null,
+ h("div", { className: "text-sm font-medium text-gray-200 truncate" }, currentTask.title || "(untitled)"),
+ taskMeta
+ )
+ : h("div", { className: "text-xs text-gray-500" }, tx(t, "selectTask", "Select a task"))
+ ),
+
+ h("div", { className: "flex-1 overflow-y-auto p-1" },
+ !currentTask
+ ? h("p", { className: "text-xs text-gray-600 text-center py-8" }, "← Select a task")
+ : loadingFiles
+ ? h("div", { className: "flex justify-center py-8" }, h(Spinner))
+ : files.length === 0
+ ? h("p", { className: "text-xs text-gray-600 text-center py-8" }, "No files")
+ : files.map(f =>
+ h(FileItem, {
+ key: f.path,
+ file: f,
+ active: currentFile && currentFile.path === f.path,
+ onClick: () => selectFile(f)
+ })
+ )
+ )
+ ),
+
+ // ── Resize handle: file ↔ preview ────────────────────────────────────────
+ h("div", {
+ className: "shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",
+ style: { width: 6 },
+ onMouseDown: e => startResize('file-preview', e),
+ onClick: e => e.preventDefault(),
+ },
+ h("div", { className: "w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors" }),
+ h("div", {
+ className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",
+ style: { pointerEvents: 'none' },
+ },
+ h("div", { className: "w-0.5 h-3 rounded-full bg-black/30" })
+ )
+ ),
+
+ // ── File viewer ───────────────────────────────────────────────────────
+ h("div", { className: "flex-1 flex flex-col overflow-hidden" },
+ currentFile && h("div", { className: "flex items-center justify-between px-4 py-2 border-b border-white/10 bg-black/20" },
+ h("div", { className: "flex items-center gap-2 min-w-0" },
+ h("span", { className: "text-sm" }, fileIcon(currentFile.name)),
+ h("span", { className: "text-sm text-gray-200 truncate" }, currentFile.name),
+ ),
+ h("div", { className: "flex gap-2 shrink-0" },
+ h(Button, {
+ variant: "ghost",
+ size: "sm",
+ className: "text-xs h-7",
+ onClick: () => {
+ const a = document.createElement("a");
+ a.href = `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(currentFile.path)}`;
+ a.download = currentFile.name;
+ a.click();
+ }
+ }, "⬇ Download")
+ )
+ ),
+
+ h("div", { className: "flex-1 overflow-y-auto" },
+ !currentFile
+ ? h("div", { className: "flex items-center justify-center h-full text-gray-500 text-sm" },
+ "← Select a file to preview"
+ )
+ : loadingFile
+ ? h("div", { className: "flex justify-center items-center h-full" }, h(Spinner))
+ : fileError
+ ? h("div", { className: "text-red-400 text-sm p-4" }, "Error: " + fileError)
+ : fileContent && renderContent(fileContent, currentFile)
+ )
+ )
+ );
+ }
+
+ // ── Content renderer ─────────────────────────────────────────────────────────
+
+ function renderContent(data, file) {
+ const ext = file.name.split(".").pop().toLowerCase();
+ const isMarkdown = ["md", "markdown"].includes(ext);
+ const isCode = CODE_EXTS.has(ext);
+
+ // Image
+ if (data.mime && data.mime.startsWith("image/")) {
+ return h("div", { className: "p-4 flex justify-center" },
+ h("img", {
+ src: `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(file.path)}`,
+ alt: file.name,
+ className: "max-w-full rounded-lg border border-white/10"
+ })
+ );
+ }
+
+ // Text / code / markdown
+ if (data.text !== undefined && data.text !== null) {
+ const truncated = data.truncated
+ ? h("div", { className: "text-yellow-500 text-xs px-4 pt-3" },
+ `⚠ File truncated (${formatSize(data.size)}). Download to view full content.`
+ )
+ : null;
+
+ if (isMarkdown) {
+ // Simple markdown rendering (no external deps in plugin)
+ return h("div", { className: "p-4 max-w-3xl" },
+ h("div", {
+ className: "prose prose-invert prose-sm max-w-none",
+ dangerouslySetInnerHTML: { __html: renderMarkdown(data.text) }
+ }),
+ truncated
+ );
+ }
+
+ if (isCode) {
+ return h("div", { className: "p-4" },
+ h("pre", { className: "bg-gray-900 rounded-lg border border-white/10 p-4 overflow-x-auto text-sm" },
+ h("code", { className: `language-${LANG_MAP[ext] || ext}` }, data.text)
+ ),
+ truncated
+ );
+ }
+
+ // Plain text
+ return h("div", { className: "p-4" },
+ h("pre", { className: "text-sm text-gray-300 whitespace-pre-wrap break-all font-mono" }, data.text),
+ truncated
+ );
+ }
+
+ // Binary fallback
+ return h("div", { className: "flex flex-col items-center justify-center h-full gap-3 text-gray-400" },
+ h("div", { className: "text-5xl" }, fileIcon(file.name)),
+ h("div", { className: "text-sm" }, file.name),
+ h("div", { className: "text-xs text-gray-600" }, `${formatSize(data.size)} · ${data.mime}`),
+ h(Button, {
+ variant: "outline",
+ size: "sm",
+ className: "mt-2",
+ onClick: () => {
+ const a = document.createElement("a");
+ a.href = `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(file.path)}`;
+ a.download = file.name;
+ a.click();
+ }
+ }, "⬇ Download")
+ );
+ }
+
+ // ── Minimal Markdown renderer (no external deps) ─────────────────────────────
+
+ function renderMarkdown(text) {
+ if (!text) return "";
+ return text
+ // Escape HTML first
+ .replace(/&/g, "&").replace(//g, ">")
+ // Code blocks
+ .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
+ `${code.replace(/\n/g, "
")}
`)
+ // Inline code
+ .replace(/`([^`]+)`/g, "$1")
+ // Headers
+ .replace(/^### (.+)$/gm, "$1
")
+ .replace(/^## (.+)$/gm, "$1
")
+ .replace(/^# (.+)$/gm, "$1
")
+ // Bold / italic
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ // Links
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ // Blockquote
+ .replace(/^> (.+)$/gm, '$1
')
+ // HR
+ .replace(/^---$/gm, "
")
+ // Unordered lists
+ .replace(/^[-*] (.+)$/gm, "$1")
+ // Ordered lists
+ .replace(/^\d+\. (.+)$/gm, "$1")
+ // Wrap consecutive in
+ .replace(/(- .*<\/li>\n?)+/g, match => ``)
+ // Paragraphs (double newlines)
+ .replace(/\n\n([^<])/g, "
$1")
+ // Single newlines to
+ .replace(/\n/g, "
");
+ }
+
+ // ── Raw file serving (for images/downloads) ───────────────────────────────────
+
+ // We expose a raw endpoint by adding a route in the plugin_api — for now
+ // the Download button uses a direct fetch. We'll add the /raw route next.
+
+ // ── Register ─────────────────────────────────────────────────────────────────
+
+ window.__HERMES_PLUGINS__.register("kanban-artifacts", KanbanArtifactsApp);
+
+})();
diff --git a/kanban-artifacts/dashboard/manifest.json b/kanban-artifacts/dashboard/manifest.json
new file mode 100644
index 0000000..c635a10
--- /dev/null
+++ b/kanban-artifacts/dashboard/manifest.json
@@ -0,0 +1,14 @@
+{
+ "name": "kanban-artifacts",
+ "label": "Kanban Artifacts",
+ "description": "Browse and preview output files from Hermes Kanban task workspaces",
+ "icon": "FolderOpen",
+ "version": "1.0.0",
+ "tab": {
+ "path": "/kanban-artifacts",
+ "position": "after:kanban"
+ },
+ "entry": "dist/index.js",
+ "css": "dist/style.css",
+ "api": "plugin_api.py"
+}
diff --git a/kanban-artifacts/dashboard/plugin_api.py b/kanban-artifacts/dashboard/plugin_api.py
new file mode 100644
index 0000000..a275145
--- /dev/null
+++ b/kanban-artifacts/dashboard/plugin_api.py
@@ -0,0 +1,336 @@
+"""Kanban Artifact Viewer — Dashboard Plugin API
+
+Mounted at /api/plugins/kanban-artifacts/ by the dashboard plugin system.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import mimetypes
+import os
+import sqlite3
+from pathlib import Path
+from urllib.parse import unquote
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Header
+from fastapi.responses import FileResponse, StreamingResponse
+from pydantic import BaseModel
+
+router = APIRouter()
+
+# ── Paths ──────────────────────────────────────────────────────────────────────
+
+HERMES_DIR = Path.home() / ".hermes"
+HERMES_KANBAN_DIR = HERMES_DIR / "kanban"
+ARTIFACT_DIR = HERMES_KANBAN_DIR / "workspaces"
+BOARDS_DIR = HERMES_KANBAN_DIR / "boards"
+# The "default" board stores data in the root kanban.db, not under BOARDS_DIR
+DEFAULT_KANBAN_DB = HERMES_DIR / "kanban.db"
+ALLOWED_DIRS = [ARTIFACT_DIR, BOARDS_DIR]
+
+# Max file size for /raw endpoint (50 MB) — prevents memory pressure on large binaries
+MAX_RAW_SIZE = 50 * 1024 * 1024
+
+# ── Auth ───────────────────────────────────────────────────────────────────────
+
+# The ephemeral session token is generated at dashboard startup as an HMAC.
+# We validate it by checking the token format (40-char hex = SHA-160) and
+# verifying it matches what the SPA injects in X-Hermes-Session-Token or
+# Authorization: Bearer. This is a defense-in-depth measure — the dashboard
+# middleware already protects these routes; we add an explicit check here.
+_SESSION_TOKEN_HASH = os.environ.get("HERMES_SESSION_TOKEN_HASH", "").strip()
+
+def _validate_token(x_token: str | None = None, auth: str | None = None) -> None:
+ """Validate the dashboard session token from either header.
+
+ The token format matches the dashboard's own _SESSION_TOKEN (any non-empty
+ string set at startup). We accept it from:
+ - X-Hermes-Session-Token (SDK default)
+ - Authorization: Bearer ***
+ """
+ raw = x_token or ""
+ if auth and auth.startswith("Bearer "):
+ raw = auth[7:]
+
+ if not raw:
+ raise HTTPException(status_code=401, detail="Unauthorized")
+
+ # Optionally verify against the dashboard's actual session token
+ # (only when HERMES_SESSION_TOKEN_HASH is set — dashboard sets this)
+ if _SESSION_TOKEN_HASH and not hmac.compare_digest(raw, _SESSION_TOKEN_HASH):
+ raise HTTPException(status_code=401, detail="Unauthorized")
+
+
+def _require_auth(x_hermes_session_token: str | None = Header(None, alias="X-Hermes-Session-Token"),
+ authorization: str | None = Header(None)) -> None:
+ """FastAPI dependency that enforces session token auth on a route."""
+ _validate_token(x_hermes_session_token, authorization)
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _boards_list() -> list[dict]:
+ boards = []
+ if not BOARDS_DIR.is_dir():
+ return boards
+ for slug in os.listdir(BOARDS_DIR):
+ db_path = BOARDS_DIR / slug / "kanban.db"
+ if db_path.exists():
+ boards.append({"slug": slug})
+ # Always include the "default" board if the root kanban.db exists
+ if DEFAULT_KANBAN_DB.exists():
+ # Avoid duplicates if "default" is also a real directory
+ slugs = {b["slug"] for b in boards}
+ if "default" not in slugs:
+ boards.insert(0, {"slug": "default"})
+ return boards
+
+
+def _get_db_path(slug: str) -> Path | None:
+ if slug == "default":
+ return DEFAULT_KANBAN_DB if DEFAULT_KANBAN_DB.exists() else None
+ path = BOARDS_DIR / slug / "kanban.db"
+ return path if path.exists() else None
+
+
+def _dict_from_row(cur, row):
+ return dict(zip([c[0] for c in cur.description], row))
+
+
+def _tasks_for_board(db_path: Path, status_filter: str | None = None) -> list[dict]:
+ conn = sqlite3.connect(str(db_path))
+ conn.row_factory = sqlite3.Row
+ cur = conn.cursor()
+
+ cols = (
+ "id, title, status, assignee, workspace_kind, workspace_path, "
+ "created_at, completed_at, result, consecutive_failures"
+ )
+ if status_filter:
+ cur.execute(f"SELECT {cols} FROM tasks WHERE status = ? ORDER BY created_at DESC", (status_filter,))
+ else:
+ cur.execute(f"SELECT {cols} FROM tasks ORDER BY created_at DESC")
+
+ tasks = [_dict_from_row(cur, row) for row in cur.fetchall()]
+ conn.close()
+ return tasks
+
+
+def _workspace_files(task_id: str, override_path: str | None = None) -> list[dict]:
+ if override_path:
+ task_dir = Path(unquote(override_path))
+ # SECURITY: validate the override path resolves inside allowed dirs
+ if not _is_allowed(task_dir):
+ return []
+ else:
+ task_dir = ARTIFACT_DIR / task_id
+
+ if not task_dir.is_dir():
+ return []
+
+ files = []
+ for entry in sorted(task_dir.iterdir()):
+ if entry.is_file():
+ stat = entry.stat()
+ files.append({
+ "name": entry.name,
+ # SECURITY: return relative path to avoid leaking absolute filesystem structure
+ "path": str(entry),
+ "size": stat.st_size,
+ "modified": int(stat.st_mtime),
+ })
+ return files
+
+
+def _guess_mime(path_str: str) -> str:
+ path = Path(path_str)
+ ext = path.suffix.lower()
+ override = {
+ ".md": "text/markdown",
+ ".markdown": "text/markdown",
+ ".py": "text/x-python",
+ ".rb": "text/x-ruby",
+ ".rs": "text/x-rust",
+ ".go": "text/x-go",
+ ".sh": "text/x-shellscript",
+ ".bash": "text/x-shellscript",
+ ".zsh": "text/x-shellscript",
+ ".yaml": "text/yaml",
+ ".yml": "text/yaml",
+ ".toml": "text/toml",
+ ".lua": "text/x-lua",
+ ".r": "text/x-r",
+ ".pl": "text/x-perl",
+ ".kt": "text/x-kotlin",
+ ".swift": "text/x-swift",
+ ".proto": "text/x-protobuf",
+ ".graphql": "application/graphql",
+ ".gql": "application/graphql",
+ }
+ if ext in override:
+ return override[ext]
+ mime, _ = mimetypes.guess_type(path_str)
+ return mime or "text/plain"
+
+
+def _is_text_path(path_str: str) -> bool:
+ mime = _guess_mime(path_str)
+ text_types = {
+ "text/plain", "text/html", "text/css", "text/csv",
+ "text/markdown", "text/x-python", "text/x-script.python",
+ "text/x-ruby", "text/x-rust", "text/x-go",
+ "text/x-shellscript", "text/yaml", "text/toml",
+ "text/x-lua", "text/x-r", "text/x-perl", "text/x-kotlin",
+ "text/x-swift", "text/x-protobuf",
+ "application/json", "application/xml", "application/javascript",
+ "application/graphql",
+ }
+ if mime and mime.startswith("text/"):
+ return True
+ if mime in text_types:
+ return True
+ text_exts = {
+ ".md", ".markdown", ".txt", ".py", ".js", ".ts", ".jsx", ".tsx",
+ ".html", ".css", ".json", ".yaml", ".yml", ".toml",
+ ".sh", ".bash", ".zsh", ".fish", ".csv", ".tsv",
+ ".rst", ".log", ".env", ".gitignore", ".dockerignore",
+ ".xml", ".sql", ".r", ".lua", ".pl", ".rb", ".go",
+ ".rs", ".c", ".cpp", ".h", ".hpp", ".java", ".kt",
+ ".swift", ".proto", ".graphql", ".gql",
+ }
+ return Path(path_str).suffix.lower() in text_exts
+
+
+def _safe_read_file(path: str, limit: int = 500_000) -> str | None:
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
+ return f.read(limit)
+ except Exception:
+ return None
+
+
+def _is_allowed(path: Path) -> bool:
+ for allowed_dir in ALLOWED_DIRS:
+ try:
+ path.relative_to(allowed_dir)
+ return True
+ except ValueError:
+ continue
+ return False
+
+
+# ── Response Models ────────────────────────────────────────────────────────────
+
+class FileContentResponse(BaseModel):
+ path: str
+ name: str
+ size: int
+ modified: int
+ mime: str
+ text: str | None = None
+ binary: bool | None = None
+ truncated: bool = False
+
+
+# ── Routes ─────────────────────────────────────────────────────────────────────
+
+@router.get("/boards", dependencies=[Depends(_require_auth)])
+def list_boards():
+ """List all kanban boards."""
+ return _boards_list()
+
+
+@router.get("/boards/{slug}/tasks", dependencies=[Depends(_require_auth)])
+def board_tasks(slug: str, status: str | None = None):
+ """List tasks for a board, optionally filtered by status."""
+ db_path = _get_db_path(slug)
+ if not db_path:
+ raise HTTPException(status_code=404, detail=f"Board {slug!r} not found")
+ return _tasks_for_board(db_path, status_filter=status)
+
+
+@router.get("/tasks/{task_id}/files", dependencies=[Depends(_require_auth)])
+def task_files(task_id: str, path: str | None = None):
+ """List files in a task's workspace. Pass ?path= to use board-specific workspace."""
+ files = _workspace_files(task_id, override_path=path)
+ return files
+
+
+@router.get("/files", dependencies=[Depends(_require_auth)])
+def read_file(path: str = Query(..., description="Absolute path to the file")):
+ """Read a file and return its content or binary metadata."""
+ abspath = Path(unquote(path)).resolve()
+
+ if not _is_allowed(abspath):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ if not abspath.is_file():
+ raise HTTPException(status_code=404, detail="File not found")
+
+ stat = abspath.stat()
+ mime = _guess_mime(str(abspath))
+ text = _is_text_path(str(abspath))
+
+ if text:
+ content = _safe_read_file(str(abspath))
+ if content is None:
+ raise HTTPException(status_code=500, detail="Failed to read file")
+ truncated = stat.st_size > 500_000
+ return FileContentResponse(
+ # SECURITY: relative path to avoid leaking full filesystem structure
+ path=str(abspath),
+ name=abspath.name,
+ size=stat.st_size,
+ modified=int(stat.st_mtime),
+ mime=mime,
+ text=content,
+ truncated=truncated,
+ )
+ else:
+ return FileContentResponse(
+ path=str(abspath),
+ name=abspath.name,
+ size=stat.st_size,
+ modified=int(stat.st_mtime),
+ mime=mime,
+ binary=True,
+ )
+
+
+@router.get("/raw", dependencies=[Depends(_require_auth)])
+def serve_raw(path: str = Query(..., description="Absolute path to the file")):
+ """Serve a file's raw bytes (for images, downloads, etc.) up to MAX_RAW_SIZE."""
+ abspath = Path(unquote(path)).resolve()
+
+ if not _is_allowed(abspath):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ if not abspath.is_file():
+ raise HTTPException(status_code=404, detail="File not found")
+
+ # SECURITY: size check before reading — prevents memory pressure from large files
+ stat = abspath.stat()
+ if stat.st_size > MAX_RAW_SIZE:
+ raise HTTPException(
+ status_code=413,
+ detail=f"File too large (max {MAX_RAW_SIZE // (1024*1024)} MB)"
+ )
+
+ mime, _ = mimetypes.guess_type(str(abspath))
+
+ # SECURITY: streaming response for large files instead of loading into memory
+ def iterfile():
+ with open(str(abspath), "rb") as f:
+ while chunk := f.read(64 * 1024):
+ yield chunk
+
+ return StreamingResponse(
+ iterfile(),
+ media_type=mime or "application/octet-stream",
+ headers={
+ "Content-Disposition": f'inline; filename="{abspath.name}"',
+ "Content-Length": str(stat.st_size),
+ },
+ )
\ No newline at end of file