agent-relay-server 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/docs/openapi.json +165 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-Bt-j5suN.js → activity-BNLuP717.js} +2 -2
  4. package/public/assets/{activity-Bt-j5suN.js.map → activity-BNLuP717.js.map} +1 -1
  5. package/public/assets/{agents-MmlXjZ8S.js → agents-CqQhC2aN.js} +2 -2
  6. package/public/assets/{agents-MmlXjZ8S.js.map → agents-CqQhC2aN.js.map} +1 -1
  7. package/public/assets/{analytics-dzycu7KU.js → analytics-44eqtAsx.js} +2 -2
  8. package/public/assets/{analytics-dzycu7KU.js.map → analytics-44eqtAsx.js.map} +1 -1
  9. package/public/assets/{automation-DKmzNNAm.js → automation-x65cxlk4.js} +2 -2
  10. package/public/assets/{automation-DKmzNNAm.js.map → automation-x65cxlk4.js.map} +1 -1
  11. package/public/assets/{chat-DKPT2jJj.js → chat-nugo0m55.js} +2 -2
  12. package/public/assets/{chat-DKPT2jJj.js.map → chat-nugo0m55.js.map} +1 -1
  13. package/public/assets/display-CXhnBwGf.js.map +1 -1
  14. package/public/assets/{formatted-body-impl-Bzso46lB.js → formatted-body-impl-UeUBqdRz.js} +2 -2
  15. package/public/assets/{formatted-body-impl-Bzso46lB.js.map → formatted-body-impl-UeUBqdRz.js.map} +1 -1
  16. package/public/assets/{index-uABGD_MX.js → index-D--CyG66.js} +5 -5
  17. package/public/assets/{index-uABGD_MX.js.map → index-D--CyG66.js.map} +1 -1
  18. package/public/assets/{insights-BczKqmA4.js → insights-C9k7mCoJ.js} +2 -2
  19. package/public/assets/{insights-BczKqmA4.js.map → insights-C9k7mCoJ.js.map} +1 -1
  20. package/public/assets/{maintenance-A3dR4k8v.js → maintenance-tgb7AMTv.js} +2 -2
  21. package/public/assets/{maintenance-A3dR4k8v.js.map → maintenance-tgb7AMTv.js.map} +1 -1
  22. package/public/assets/{managed-agents-65TJRBdv.js → managed-agents-DJ3TfBJ5.js} +2 -2
  23. package/public/assets/{managed-agents-65TJRBdv.js.map → managed-agents-DJ3TfBJ5.js.map} +1 -1
  24. package/public/assets/{markdown-preview-impl-pevH3csB.js → markdown-preview-impl-DcHq1Hto.js} +2 -2
  25. package/public/assets/{markdown-preview-impl-pevH3csB.js.map → markdown-preview-impl-DcHq1Hto.js.map} +1 -1
  26. package/public/assets/{memory-B3CXqWQf.js → memory-CMdemdtb.js} +2 -2
  27. package/public/assets/{memory-B3CXqWQf.js.map → memory-CMdemdtb.js.map} +1 -1
  28. package/public/assets/{messages-BV48hQsO.js → messages-CLuLC9wC.js} +2 -2
  29. package/public/assets/{messages-BV48hQsO.js.map → messages-CLuLC9wC.js.map} +1 -1
  30. package/public/assets/{orchestrators-CPU7U-s7.js → orchestrators-d27zYyT9.js} +2 -2
  31. package/public/assets/{orchestrators-CPU7U-s7.js.map → orchestrators-d27zYyT9.js.map} +1 -1
  32. package/public/assets/{overview-mR70u7ea.js → overview-BY-cs5SI.js} +2 -2
  33. package/public/assets/{overview-mR70u7ea.js.map → overview-BY-cs5SI.js.map} +1 -1
  34. package/public/assets/{pairs-B8KJc296.js → pairs-CmyQx_4F.js} +2 -2
  35. package/public/assets/{pairs-B8KJc296.js.map → pairs-CmyQx_4F.js.map} +1 -1
  36. package/public/assets/{security-Dc74nqW8.js → security-CxCnWTlk.js} +2 -2
  37. package/public/assets/{security-Dc74nqW8.js.map → security-CxCnWTlk.js.map} +1 -1
  38. package/public/assets/{select-kIw3Jm8E.js → select-B6iG4oYX.js} +2 -2
  39. package/public/assets/{select-kIw3Jm8E.js.map → select-B6iG4oYX.js.map} +1 -1
  40. package/public/assets/{settings-XrFz0Q3b.js → settings-vSYPv8kE.js} +2 -2
  41. package/public/assets/{settings-XrFz0Q3b.js.map → settings-vSYPv8kE.js.map} +1 -1
  42. package/public/assets/{tasks-TY2QGHHg.js → tasks-BYUAWEL-.js} +2 -2
  43. package/public/assets/{tasks-TY2QGHHg.js.map → tasks-BYUAWEL-.js.map} +1 -1
  44. package/public/assets/teams-gWvyw7ET.js +2 -0
  45. package/public/assets/teams-gWvyw7ET.js.map +1 -0
  46. package/public/assets/{terminal-viewer-impl-D3du9G9E.js → terminal-viewer-impl-CHP0w79T.js} +2 -2
  47. package/public/assets/{terminal-viewer-impl-D3du9G9E.js.map → terminal-viewer-impl-CHP0w79T.js.map} +1 -1
  48. package/public/assets/{work-queue-B2nZp_vj.js → work-queue-CBjXlNuG.js} +2 -2
  49. package/public/assets/{work-queue-B2nZp_vj.js.map → work-queue-CBjXlNuG.js.map} +1 -1
  50. package/public/index.html +1 -1
  51. package/src/config-store.ts +2 -0
  52. package/src/mcp-team-template-tools.ts +133 -0
  53. package/src/mcp-team-tools.ts +4 -1
  54. package/src/mcp.ts +1 -1
  55. package/src/routes/index.ts +6 -0
  56. package/src/routes/team-templates.ts +65 -0
  57. package/src/services/team-templates.ts +172 -0
  58. package/src/team-template-config.ts +100 -0
  59. package/public/assets/teams-DMQN2gCU.js +0 -2
  60. package/public/assets/teams-DMQN2gCU.js.map +0 -1
@@ -1,2 +1,2 @@
1
- import{Qt as e,at as t,i as n,jn as r,ln as i}from"./lucide-react-ChOGD4vc.js";import{o as a,t as o}from"./store-BLZA3u7Y.js";import{H as s,ct as c,d as l}from"./display-CXhnBwGf.js";import{t as u}from"./badge-BR4bvEsy.js";import{t as d}from"./button-BwqJZS4O.js";import{R as f,W as p,s as m}from"./index-uABGD_MX.js";import{n as h,t as g}from"./card-C7JtSyuM.js";var _=r(),v={open:`bg-blue-500/10 text-blue-400 border-blue-500/20`,blocked:`bg-yellow-500/10 text-yellow-400 border-yellow-500/20`,claimed:`bg-emerald-500/10 text-emerald-400 border-emerald-500/20`,in_progress:`bg-purple-500/10 text-purple-400 border-purple-500/20`};function y(){let e=a(),n=o(e=>e.agentsById),r=o(e=>e.compose),i=o(e=>e.set),s=o(e=>e.doClaimTask),c=o(e=>e.doClaim),d=o(e=>e.openTaskEvents),h=o(e=>e.showError),g=o(e=>e.openConfirm),v=o(e=>e.doDeleteMessage),y=o(e=>e.doUpdateTaskStatus),x=f(),S=p(),C=S.filter(e=>e.claimable).length;function w(e){if(!r.from){h(`Validation`,`Select a "Claim as" agent first.`);return}e.sourceType===`task`&&e.task?s(e.task.id):e.sourceType===`message`&&e.message&&c(e.message.id)}function T(e){g(`Cancel Item`,`Cancel "${e.title||(e.sourceType===`task`?`Task #${e.id}`:`Message #${e.id}`)}"? This cannot be undone.`,()=>{e.sourceType===`task`&&e.task?y(e.task,`canceled`):e.sourceType===`message`&&e.message&&v(e.message.id)})}function E(e){e.task&&d(e.task)}return(0,_.jsxs)(`div`,{className:`space-y-4`,children:[(0,_.jsxs)(`div`,{className:`flex items-center justify-between flex-wrap gap-2`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(t,{className:`w-5 h-5 text-muted-foreground`}),(0,_.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Work Queue`}),C>0&&(0,_.jsxs)(u,{className:`bg-orange-500/20 text-orange-400 border-orange-500/30 border`,children:[C,` claimable`]})]}),(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`label`,{className:`text-xs text-muted-foreground shrink-0`,children:`Claim as`}),(0,_.jsxs)(`select`,{className:`rounded-md border border-input bg-background px-3 py-1.5 text-sm`,value:r.from,onChange:e=>i({compose:{...r,from:e.target.value}}),children:[(0,_.jsx)(`option`,{value:``,children:`Select agent...`}),x.map(e=>(0,_.jsx)(`option`,{value:e.id,children:l(e)},e.id))]})]})]}),(0,_.jsx)(m,{className:`h-[calc(100dvh-11rem)]`,children:(0,_.jsxs)(`div`,{className:`space-y-2 pr-2`,children:[S.length===0&&(0,_.jsx)(`div`,{className:`text-center text-muted-foreground py-16 text-sm`,children:`Work queue is empty`}),S.map(t=>(0,_.jsx)(b,{item:t,now:e,agentName:e=>n[e]?l(n[e]):e.slice(-10),onClaim:()=>w(t),onCancel:()=>T(t),onEvents:t.task?()=>E(t):void 0},t.id))]})})]})}function b({item:t,now:r,agentName:a,onClaim:o,onCancel:l,onEvents:f}){let p=c[t.severity]||c.info,m=v[t.status]||`bg-zinc-500/10 text-zinc-400 border-zinc-500/20`;return(0,_.jsx)(g,{className:t.claimable?`ring-1 ring-orange-500/30`:``,children:(0,_.jsx)(h,{className:`p-3`,children:(0,_.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2 flex-wrap mb-1`,children:[(0,_.jsx)(`span`,{className:`font-medium text-sm truncate`,children:t.title}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${p}`,children:t.severity}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${m}`,children:t.status}),t.sourceType===`message`&&(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400`,children:`msg`})]}),t.body&&(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground line-clamp-2 mb-2`,children:t.body}),(0,_.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground flex-wrap`,children:[t.owner&&(0,_.jsxs)(`span`,{title:`Owner`,children:[(0,_.jsx)(e,{className:`w-3 h-3 inline mr-1`}),a(t.owner)]}),t.source&&(0,_.jsx)(`span`,{className:`font-mono`,children:t.source}),(0,_.jsxs)(`span`,{title:String(t.updatedAt),children:[(0,_.jsx)(i,{className:`w-3 h-3 inline mr-1`}),s(r,t.updatedAt)]})]})]}),(0,_.jsxs)(`div`,{className:`flex flex-col gap-1 shrink-0`,children:[t.claimable&&(0,_.jsxs)(d,{size:`sm`,className:`h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white`,onClick:o,children:[(0,_.jsx)(e,{className:`w-3.5 h-3.5 mr-1`}),` Claim`]}),f&&(0,_.jsx)(d,{size:`sm`,variant:`outline`,className:`h-7 text-xs`,onClick:f,children:`Events`}),(0,_.jsxs)(d,{size:`sm`,variant:`outline`,className:`h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20`,onClick:l,children:[(0,_.jsx)(n,{className:`w-3.5 h-3.5 mr-1`}),` Cancel`]})]})]})})})}export{y as WorkQueueView};
2
- //# sourceMappingURL=work-queue-B2nZp_vj.js.map
1
+ import{Qt as e,at as t,i as n,jn as r,ln as i}from"./lucide-react-ChOGD4vc.js";import{o as a,t as o}from"./store-BLZA3u7Y.js";import{H as s,ct as c,d as l}from"./display-CXhnBwGf.js";import{t as u}from"./badge-BR4bvEsy.js";import{t as d}from"./button-BwqJZS4O.js";import{R as f,W as p,s as m}from"./index-D--CyG66.js";import{n as h,t as g}from"./card-C7JtSyuM.js";var _=r(),v={open:`bg-blue-500/10 text-blue-400 border-blue-500/20`,blocked:`bg-yellow-500/10 text-yellow-400 border-yellow-500/20`,claimed:`bg-emerald-500/10 text-emerald-400 border-emerald-500/20`,in_progress:`bg-purple-500/10 text-purple-400 border-purple-500/20`};function y(){let e=a(),n=o(e=>e.agentsById),r=o(e=>e.compose),i=o(e=>e.set),s=o(e=>e.doClaimTask),c=o(e=>e.doClaim),d=o(e=>e.openTaskEvents),h=o(e=>e.showError),g=o(e=>e.openConfirm),v=o(e=>e.doDeleteMessage),y=o(e=>e.doUpdateTaskStatus),x=f(),S=p(),C=S.filter(e=>e.claimable).length;function w(e){if(!r.from){h(`Validation`,`Select a "Claim as" agent first.`);return}e.sourceType===`task`&&e.task?s(e.task.id):e.sourceType===`message`&&e.message&&c(e.message.id)}function T(e){g(`Cancel Item`,`Cancel "${e.title||(e.sourceType===`task`?`Task #${e.id}`:`Message #${e.id}`)}"? This cannot be undone.`,()=>{e.sourceType===`task`&&e.task?y(e.task,`canceled`):e.sourceType===`message`&&e.message&&v(e.message.id)})}function E(e){e.task&&d(e.task)}return(0,_.jsxs)(`div`,{className:`space-y-4`,children:[(0,_.jsxs)(`div`,{className:`flex items-center justify-between flex-wrap gap-2`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(t,{className:`w-5 h-5 text-muted-foreground`}),(0,_.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Work Queue`}),C>0&&(0,_.jsxs)(u,{className:`bg-orange-500/20 text-orange-400 border-orange-500/30 border`,children:[C,` claimable`]})]}),(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`label`,{className:`text-xs text-muted-foreground shrink-0`,children:`Claim as`}),(0,_.jsxs)(`select`,{className:`rounded-md border border-input bg-background px-3 py-1.5 text-sm`,value:r.from,onChange:e=>i({compose:{...r,from:e.target.value}}),children:[(0,_.jsx)(`option`,{value:``,children:`Select agent...`}),x.map(e=>(0,_.jsx)(`option`,{value:e.id,children:l(e)},e.id))]})]})]}),(0,_.jsx)(m,{className:`h-[calc(100dvh-11rem)]`,children:(0,_.jsxs)(`div`,{className:`space-y-2 pr-2`,children:[S.length===0&&(0,_.jsx)(`div`,{className:`text-center text-muted-foreground py-16 text-sm`,children:`Work queue is empty`}),S.map(t=>(0,_.jsx)(b,{item:t,now:e,agentName:e=>n[e]?l(n[e]):e.slice(-10),onClaim:()=>w(t),onCancel:()=>T(t),onEvents:t.task?()=>E(t):void 0},t.id))]})})]})}function b({item:t,now:r,agentName:a,onClaim:o,onCancel:l,onEvents:f}){let p=c[t.severity]||c.info,m=v[t.status]||`bg-zinc-500/10 text-zinc-400 border-zinc-500/20`;return(0,_.jsx)(g,{className:t.claimable?`ring-1 ring-orange-500/30`:``,children:(0,_.jsx)(h,{className:`p-3`,children:(0,_.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2 flex-wrap mb-1`,children:[(0,_.jsx)(`span`,{className:`font-medium text-sm truncate`,children:t.title}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${p}`,children:t.severity}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${m}`,children:t.status}),t.sourceType===`message`&&(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400`,children:`msg`})]}),t.body&&(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground line-clamp-2 mb-2`,children:t.body}),(0,_.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground flex-wrap`,children:[t.owner&&(0,_.jsxs)(`span`,{title:`Owner`,children:[(0,_.jsx)(e,{className:`w-3 h-3 inline mr-1`}),a(t.owner)]}),t.source&&(0,_.jsx)(`span`,{className:`font-mono`,children:t.source}),(0,_.jsxs)(`span`,{title:String(t.updatedAt),children:[(0,_.jsx)(i,{className:`w-3 h-3 inline mr-1`}),s(r,t.updatedAt)]})]})]}),(0,_.jsxs)(`div`,{className:`flex flex-col gap-1 shrink-0`,children:[t.claimable&&(0,_.jsxs)(d,{size:`sm`,className:`h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white`,onClick:o,children:[(0,_.jsx)(e,{className:`w-3.5 h-3.5 mr-1`}),` Claim`]}),f&&(0,_.jsx)(d,{size:`sm`,variant:`outline`,className:`h-7 text-xs`,onClick:f,children:`Events`}),(0,_.jsxs)(d,{size:`sm`,variant:`outline`,className:`h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20`,onClick:l,children:[(0,_.jsx)(n,{className:`w-3.5 h-3.5 mr-1`}),` Cancel`]})]})]})})})}export{y as WorkQueueView};
2
+ //# sourceMappingURL=work-queue-CBjXlNuG.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"work-queue-B2nZp_vj.js","names":[],"sources":["../../dashboard/src/components/views/work-queue.tsx"],"sourcesContent":["import { useRelayStore, useNow } from '@/store'\nimport { useWorkQueueItems, useComposeAgents } from '@/hooks/use-selectors'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { ListChecks, CheckCircle2, Calendar, X } from 'lucide-react'\nimport { displayName, timeAgo } from '@/lib/display'\nimport { SEVERITY_COLORS } from '@/lib/constants'\nimport type { WorkQueueItem } from '@/types'\n\nconst STATUS_COLORS: Record<string, string> = {\n open: 'bg-blue-500/10 text-blue-400 border-blue-500/20',\n blocked: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',\n claimed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',\n in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20',\n}\n\nexport function WorkQueueView() {\n const now = useNow()\n const agentsById = useRelayStore((s) => s.agentsById)\n const compose = useRelayStore((s) => s.compose)\n const set = useRelayStore((s) => s.set)\n const doClaimTask = useRelayStore((s) => s.doClaimTask)\n const doClaim = useRelayStore((s) => s.doClaim)\n const openTaskEvents = useRelayStore((s) => s.openTaskEvents)\n const showError = useRelayStore((s) => s.showError)\n const openConfirm = useRelayStore((s) => s.openConfirm)\n const doDeleteMessage = useRelayStore((s) => s.doDeleteMessage)\n const doUpdateTaskStatus = useRelayStore((s) => s.doUpdateTaskStatus)\n\n const composeAgents = useComposeAgents()\n const items = useWorkQueueItems()\n\n const claimableCount = items.filter((i) => i.claimable).length\n\n function handleClaim(item: WorkQueueItem) {\n if (!compose.from) { showError('Validation', 'Select a \"Claim as\" agent first.'); return }\n if (item.sourceType === 'task' && item.task) doClaimTask(item.task.id)\n else if (item.sourceType === 'message' && item.message) doClaim(item.message.id)\n }\n\n function handleCancel(item: WorkQueueItem) {\n const label = item.title || (item.sourceType === 'task' ? `Task #${item.id}` : `Message #${item.id}`)\n openConfirm('Cancel Item', `Cancel \"${label}\"? This cannot be undone.`, () => {\n if (item.sourceType === 'task' && item.task) doUpdateTaskStatus(item.task, 'canceled')\n else if (item.sourceType === 'message' && item.message) doDeleteMessage(item.message.id)\n })\n }\n\n function handleEvents(item: WorkQueueItem) {\n if (item.task) openTaskEvents(item.task)\n }\n\n return (\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between flex-wrap gap-2\">\n <div className=\"flex items-center gap-2\">\n <ListChecks className=\"w-5 h-5 text-muted-foreground\" />\n <h2 className=\"text-lg font-semibold\">Work Queue</h2>\n {claimableCount > 0 && (\n <Badge className=\"bg-orange-500/20 text-orange-400 border-orange-500/30 border\">\n {claimableCount} claimable\n </Badge>\n )}\n </div>\n <div className=\"flex items-center gap-2\">\n <label className=\"text-xs text-muted-foreground shrink-0\">Claim as</label>\n <select\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm\"\n value={compose.from}\n onChange={(e) => set({ compose: { ...compose, from: e.target.value } })}\n >\n <option value=\"\">Select agent...</option>\n {composeAgents.map((a) => <option key={a.id} value={a.id}>{displayName(a)}</option>)}\n </select>\n </div>\n </div>\n\n <ScrollArea className=\"h-[calc(100dvh-11rem)]\">\n <div className=\"space-y-2 pr-2\">\n {items.length === 0 && (\n <div className=\"text-center text-muted-foreground py-16 text-sm\">Work queue is empty</div>\n )}\n {items.map((item) => (\n <WorkQueueCard\n key={item.id}\n item={item}\n now={now}\n agentName={(id) => agentsById[id] ? displayName(agentsById[id]) : id.slice(-10)}\n onClaim={() => handleClaim(item)}\n onCancel={() => handleCancel(item)}\n onEvents={item.task ? () => handleEvents(item) : undefined}\n />\n ))}\n </div>\n </ScrollArea>\n </div>\n )\n}\n\nfunction WorkQueueCard({\n item, now, agentName, onClaim, onCancel, onEvents,\n}: {\n item: WorkQueueItem\n now: number\n agentName: (id: string) => string\n onClaim: () => void\n onCancel: () => void\n onEvents?: () => void\n}) {\n const severityClass = SEVERITY_COLORS[item.severity] || SEVERITY_COLORS.info\n const statusClass = STATUS_COLORS[item.status] || 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'\n\n return (\n <Card className={item.claimable ? 'ring-1 ring-orange-500/30' : ''}>\n <CardContent className=\"p-3\">\n <div className=\"flex items-start gap-3\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap mb-1\">\n <span className=\"font-medium text-sm truncate\">{item.title}</span>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${severityClass}`}>{item.severity}</Badge>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${statusClass}`}>{item.status}</Badge>\n {item.sourceType === 'message' && (\n <Badge variant=\"outline\" className=\"text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400\">msg</Badge>\n )}\n </div>\n {item.body && (\n <p className=\"text-xs text-muted-foreground line-clamp-2 mb-2\">{item.body}</p>\n )}\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground flex-wrap\">\n {item.owner && (\n <span title=\"Owner\">\n <CheckCircle2 className=\"w-3 h-3 inline mr-1\" />{agentName(item.owner)}\n </span>\n )}\n {item.source && (\n <span className=\"font-mono\">{item.source}</span>\n )}\n <span title={String(item.updatedAt)}>\n <Calendar className=\"w-3 h-3 inline mr-1\" />{timeAgo(now, item.updatedAt)}\n </span>\n </div>\n </div>\n <div className=\"flex flex-col gap-1 shrink-0\">\n {item.claimable && (\n <Button size=\"sm\" className=\"h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white\" onClick={onClaim}>\n <CheckCircle2 className=\"w-3.5 h-3.5 mr-1\" /> Claim\n </Button>\n )}\n {onEvents && (\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs\" onClick={onEvents}>\n Events\n </Button>\n )}\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20\" onClick={onCancel}>\n <X className=\"w-3.5 h-3.5 mr-1\" /> Cancel\n </Button>\n </div>\n </div>\n </CardContent>\n </Card>\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"work-queue-CBjXlNuG.js","names":[],"sources":["../../dashboard/src/components/views/work-queue.tsx"],"sourcesContent":["import { useRelayStore, useNow } from '@/store'\nimport { useWorkQueueItems, useComposeAgents } from '@/hooks/use-selectors'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { ListChecks, CheckCircle2, Calendar, X } from 'lucide-react'\nimport { displayName, timeAgo } from '@/lib/display'\nimport { SEVERITY_COLORS } from '@/lib/constants'\nimport type { WorkQueueItem } from '@/types'\n\nconst STATUS_COLORS: Record<string, string> = {\n open: 'bg-blue-500/10 text-blue-400 border-blue-500/20',\n blocked: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',\n claimed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',\n in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20',\n}\n\nexport function WorkQueueView() {\n const now = useNow()\n const agentsById = useRelayStore((s) => s.agentsById)\n const compose = useRelayStore((s) => s.compose)\n const set = useRelayStore((s) => s.set)\n const doClaimTask = useRelayStore((s) => s.doClaimTask)\n const doClaim = useRelayStore((s) => s.doClaim)\n const openTaskEvents = useRelayStore((s) => s.openTaskEvents)\n const showError = useRelayStore((s) => s.showError)\n const openConfirm = useRelayStore((s) => s.openConfirm)\n const doDeleteMessage = useRelayStore((s) => s.doDeleteMessage)\n const doUpdateTaskStatus = useRelayStore((s) => s.doUpdateTaskStatus)\n\n const composeAgents = useComposeAgents()\n const items = useWorkQueueItems()\n\n const claimableCount = items.filter((i) => i.claimable).length\n\n function handleClaim(item: WorkQueueItem) {\n if (!compose.from) { showError('Validation', 'Select a \"Claim as\" agent first.'); return }\n if (item.sourceType === 'task' && item.task) doClaimTask(item.task.id)\n else if (item.sourceType === 'message' && item.message) doClaim(item.message.id)\n }\n\n function handleCancel(item: WorkQueueItem) {\n const label = item.title || (item.sourceType === 'task' ? `Task #${item.id}` : `Message #${item.id}`)\n openConfirm('Cancel Item', `Cancel \"${label}\"? This cannot be undone.`, () => {\n if (item.sourceType === 'task' && item.task) doUpdateTaskStatus(item.task, 'canceled')\n else if (item.sourceType === 'message' && item.message) doDeleteMessage(item.message.id)\n })\n }\n\n function handleEvents(item: WorkQueueItem) {\n if (item.task) openTaskEvents(item.task)\n }\n\n return (\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between flex-wrap gap-2\">\n <div className=\"flex items-center gap-2\">\n <ListChecks className=\"w-5 h-5 text-muted-foreground\" />\n <h2 className=\"text-lg font-semibold\">Work Queue</h2>\n {claimableCount > 0 && (\n <Badge className=\"bg-orange-500/20 text-orange-400 border-orange-500/30 border\">\n {claimableCount} claimable\n </Badge>\n )}\n </div>\n <div className=\"flex items-center gap-2\">\n <label className=\"text-xs text-muted-foreground shrink-0\">Claim as</label>\n <select\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm\"\n value={compose.from}\n onChange={(e) => set({ compose: { ...compose, from: e.target.value } })}\n >\n <option value=\"\">Select agent...</option>\n {composeAgents.map((a) => <option key={a.id} value={a.id}>{displayName(a)}</option>)}\n </select>\n </div>\n </div>\n\n <ScrollArea className=\"h-[calc(100dvh-11rem)]\">\n <div className=\"space-y-2 pr-2\">\n {items.length === 0 && (\n <div className=\"text-center text-muted-foreground py-16 text-sm\">Work queue is empty</div>\n )}\n {items.map((item) => (\n <WorkQueueCard\n key={item.id}\n item={item}\n now={now}\n agentName={(id) => agentsById[id] ? displayName(agentsById[id]) : id.slice(-10)}\n onClaim={() => handleClaim(item)}\n onCancel={() => handleCancel(item)}\n onEvents={item.task ? () => handleEvents(item) : undefined}\n />\n ))}\n </div>\n </ScrollArea>\n </div>\n )\n}\n\nfunction WorkQueueCard({\n item, now, agentName, onClaim, onCancel, onEvents,\n}: {\n item: WorkQueueItem\n now: number\n agentName: (id: string) => string\n onClaim: () => void\n onCancel: () => void\n onEvents?: () => void\n}) {\n const severityClass = SEVERITY_COLORS[item.severity] || SEVERITY_COLORS.info\n const statusClass = STATUS_COLORS[item.status] || 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'\n\n return (\n <Card className={item.claimable ? 'ring-1 ring-orange-500/30' : ''}>\n <CardContent className=\"p-3\">\n <div className=\"flex items-start gap-3\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap mb-1\">\n <span className=\"font-medium text-sm truncate\">{item.title}</span>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${severityClass}`}>{item.severity}</Badge>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${statusClass}`}>{item.status}</Badge>\n {item.sourceType === 'message' && (\n <Badge variant=\"outline\" className=\"text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400\">msg</Badge>\n )}\n </div>\n {item.body && (\n <p className=\"text-xs text-muted-foreground line-clamp-2 mb-2\">{item.body}</p>\n )}\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground flex-wrap\">\n {item.owner && (\n <span title=\"Owner\">\n <CheckCircle2 className=\"w-3 h-3 inline mr-1\" />{agentName(item.owner)}\n </span>\n )}\n {item.source && (\n <span className=\"font-mono\">{item.source}</span>\n )}\n <span title={String(item.updatedAt)}>\n <Calendar className=\"w-3 h-3 inline mr-1\" />{timeAgo(now, item.updatedAt)}\n </span>\n </div>\n </div>\n <div className=\"flex flex-col gap-1 shrink-0\">\n {item.claimable && (\n <Button size=\"sm\" className=\"h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white\" onClick={onClaim}>\n <CheckCircle2 className=\"w-3.5 h-3.5 mr-1\" /> Claim\n </Button>\n )}\n {onEvents && (\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs\" onClick={onEvents}>\n Events\n </Button>\n )}\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20\" onClick={onCancel}>\n <X className=\"w-3.5 h-3.5 mr-1\" /> Cancel\n </Button>\n </div>\n </div>\n </CardContent>\n </Card>\n )\n}\n"],"mappings":"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"}
package/public/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2309090b'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
13
13
  <link rel="manifest" href="manifest.webmanifest">
14
14
  <link rel="apple-touch-icon" href="icons/agent-relay-192.png">
15
- <script type="module" crossorigin src="./assets/index-uABGD_MX.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-D--CyG66.js"></script>
16
16
  <link rel="modulepreload" crossorigin href="./assets/chunk-CilyBKbf.js">
17
17
  <link rel="modulepreload" crossorigin href="./assets/preload-helper-CP_edrrL.js">
18
18
  <link rel="modulepreload" crossorigin href="./assets/lucide-react-ChOGD4vc.js">
@@ -2,6 +2,7 @@ import { getDb, ValidationError } from "./db";
2
2
  import { cleanEnum, cleanString, cleanStringArray } from "./validation";
3
3
  import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
4
4
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
5
+ import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
5
6
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
6
7
  import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
7
8
  import type {
@@ -532,6 +533,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
532
533
  if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
533
534
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
534
535
  if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
536
+ if (namespace === TEAM_TEMPLATE_NAMESPACE) return validateTeamTemplate(key, value);
535
537
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
536
538
  return value;
537
539
  }
@@ -0,0 +1,133 @@
1
+ import { APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
2
+ import { McpAuthError } from "./mcp-errors";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { deleteTeamTemplate, getTeamTemplate, instantiateTeamTemplate, listTeamTemplates, saveTeamTemplate } from "./services/team-templates";
5
+ import { ValidationError } from "./db";
6
+
7
+ const COMPOSITION_ENTRY_SCHEMA = {
8
+ type: "object",
9
+ properties: {
10
+ label: { type: "string" },
11
+ provider: { type: "string", enum: ["claude", "codex"] },
12
+ model: { type: "string" },
13
+ effort: { type: "string" },
14
+ profile: { type: "string" },
15
+ count: { type: "integer", minimum: 1, maximum: 50 },
16
+ },
17
+ required: ["label", "provider", "count"],
18
+ additionalProperties: false,
19
+ } as const;
20
+
21
+ const PLAN_SKELETON_SCHEMA = {
22
+ type: "object",
23
+ properties: {
24
+ nodes: { type: "array", items: { type: "object" } },
25
+ edges: { type: "array", items: { type: "object" } },
26
+ },
27
+ additionalProperties: false,
28
+ } as const;
29
+
30
+ export const TEAM_TEMPLATE_TOOLS = [
31
+ {
32
+ name: "relay_team_template_save",
33
+ description: "Create/update a reusable team template, or capture one from a dissolved team's frozen composition by passing teamId.",
34
+ requiredScopes: ["teams:write"],
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ id: { type: "string" },
39
+ slug: { type: "string" },
40
+ title: { type: "string" },
41
+ description: { type: "string" },
42
+ composition: { type: "array", items: COMPOSITION_ENTRY_SCHEMA },
43
+ brief: { type: "string" },
44
+ definitionOfDone: { type: "string" },
45
+ planSkeleton: PLAN_SKELETON_SCHEMA,
46
+ teamId: { type: "string", description: "Capture from this dissolved team's frozen composition snapshot instead of passing composition." },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ },
51
+ {
52
+ name: "relay_team_template_list",
53
+ description: "List saved team templates.",
54
+ requiredScopes: ["teams:read"],
55
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
56
+ },
57
+ {
58
+ name: "relay_team_template_get",
59
+ description: "Fetch one saved team template by slug.",
60
+ requiredScopes: ["teams:read"],
61
+ inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"], additionalProperties: false },
62
+ },
63
+ {
64
+ name: "relay_team_template_delete",
65
+ description: "Delete one saved team template by slug.",
66
+ requiredScopes: ["teams:write"],
67
+ inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"], additionalProperties: false },
68
+ },
69
+ {
70
+ name: "relay_team_spawn_from_template",
71
+ description: "Instantiate a saved team template: set the caller's team brief and spawn its composition through the shared spawn service.",
72
+ requiredScopes: ["teams:write"],
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ slug: { type: "string" },
77
+ orchestratorId: { type: "string" },
78
+ cwd: { type: "string" },
79
+ approvalMode: { type: "string", enum: APPROVAL_MODES },
80
+ workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES },
81
+ lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES },
82
+ prompt: { type: "string" },
83
+ waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000 },
84
+ },
85
+ required: ["slug"],
86
+ additionalProperties: false,
87
+ },
88
+ },
89
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
90
+
91
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
92
+
93
+ function hasScope(auth: McpAuthLike, scope: string): boolean {
94
+ if (auth.scopes?.includes("*") || auth.scopes?.includes("admin:*")) return true;
95
+ const [category] = scope.split(":");
96
+ return Boolean(auth.scopes?.includes(scope) || (category && auth.scopes?.includes(`${category}:*`)));
97
+ }
98
+
99
+ function requireScope(auth: McpAuthLike, scope: string): void {
100
+ if (!hasScope(auth, scope)) throw new McpAuthError(`tool requires ${scope}`);
101
+ }
102
+
103
+ function slugArg(args: Record<string, unknown>): string {
104
+ const slug = args.slug;
105
+ if (typeof slug !== "string" || !slug.trim()) throw new ValidationError("slug required");
106
+ return slug.trim();
107
+ }
108
+
109
+ export async function relayTeamTemplateTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
110
+ if (name === "relay_team_template_save") {
111
+ requireScope(auth, "teams:write");
112
+ return saveTeamTemplate(args, auth.actor) as unknown as Record<string, unknown>;
113
+ }
114
+ if (name === "relay_team_template_list") return { templates: listTeamTemplates() };
115
+ if (name === "relay_team_template_get") {
116
+ const template = getTeamTemplate(slugArg(args));
117
+ if (!template) throw new ValidationError("team template not found");
118
+ return { template };
119
+ }
120
+ if (name === "relay_team_template_delete") {
121
+ requireScope(auth, "teams:write");
122
+ return { slug: slugArg(args), deleted: deleteTeamTemplate(slugArg(args), auth.actor) };
123
+ }
124
+ if (name === "relay_team_spawn_from_template") {
125
+ requireScope(auth, "teams:write");
126
+ return await instantiateTeamTemplate({ ...args, slug: slugArg(args) }, authContextFromMcp(auth)) as unknown as Record<string, unknown>;
127
+ }
128
+ throw new ValidationError(`unknown tool: ${name}`);
129
+ }
130
+
131
+ export function isTeamTemplateTool(name: string): boolean {
132
+ return name.startsWith("relay_team_template_") || name === "relay_team_spawn_from_template";
133
+ }
@@ -4,6 +4,7 @@ import { ValidationError } from "./db";
4
4
  import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "./services/team";
5
5
  import { cleanString, optionalEnum } from "./validation";
6
6
  import type { Team } from "./types";
7
+ import { TEAM_TEMPLATE_TOOLS, isTeamTemplateTool, relayTeamTemplateTool } from "./mcp-team-template-tools";
7
8
 
8
9
  const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
9
10
 
@@ -46,6 +47,7 @@ export const TEAM_TOOLS = [
46
47
  additionalProperties: false,
47
48
  },
48
49
  },
50
+ ...TEAM_TEMPLATE_TOOLS,
49
51
  ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
50
52
 
51
53
  type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
@@ -56,7 +58,8 @@ function mapTeamError(error: unknown): never {
56
58
  throw error;
57
59
  }
58
60
 
59
- export function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
61
+ export async function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
62
+ if (isTeamTemplateTool(name)) return await relayTeamTemplateTool(auth, name, args);
60
63
  const ctx = authContextFromMcp(auth);
61
64
  try {
62
65
  if (name === "relay_team_get") {
package/src/mcp.ts CHANGED
@@ -570,7 +570,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
570
570
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
571
571
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
572
572
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
573
- else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
573
+ else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
574
574
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
575
575
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
576
576
  else throw new ValidationError(`unknown tool: ${name}`);
@@ -33,6 +33,7 @@ import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute,
33
33
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
34
34
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
35
35
  import { postTeamDissolveByIdRoute } from "./teams";
36
+ import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
36
37
  import { postMcp } from "../mcp";
37
38
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
38
39
 
@@ -144,6 +145,11 @@ const routes: Route[] = [
144
145
  route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
145
146
  route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
146
147
  route("POST", "/api/teams/:id/dissolve", postTeamDissolveByIdRoute),
148
+ route("GET", "/api/team-templates", getTeamTemplatesRoute),
149
+ route("POST", "/api/team-templates", postTeamTemplateRoute),
150
+ route("GET", "/api/team-templates/:slug", getTeamTemplateRoute),
151
+ route("PUT", "/api/team-templates/:slug", putTeamTemplateRoute),
152
+ route("DELETE", "/api/team-templates/:slug", deleteTeamTemplateRoute),
147
153
  route("GET", "/api/recipes", getRecipes),
148
154
  route("POST", "/api/recipes/start", postRecipeStart),
149
155
  route("GET", "/api/recipes/instances", getRecipeInstances),
@@ -0,0 +1,65 @@
1
+ import { authContextFromRequest } from "../services/auth-context";
2
+ import { deleteTeamTemplate, getTeamTemplate, listTeamTemplates, saveTeamTemplate } from "../services/team-templates";
3
+ import { cleanString } from "../validation";
4
+ import { authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
5
+
6
+ function updatedBy(req: Request): string {
7
+ return authContextFromRequest(req).actor.id;
8
+ }
9
+
10
+ function routeError(err: unknown): Response {
11
+ if (err instanceof Error) return error(err.message);
12
+ return error("team template route failed");
13
+ }
14
+
15
+ function slugParam(params: Record<string, string | undefined>): string {
16
+ return cleanString(params.slug, "slug", { required: true, max: 120 })!;
17
+ }
18
+
19
+ export const getTeamTemplatesRoute: Handler = (req) => {
20
+ const denied = authorizeRoute(req, { scope: "system:admin" });
21
+ if (denied) return denied;
22
+ return json({ templates: listTeamTemplates() });
23
+ };
24
+
25
+ export const getTeamTemplateRoute: Handler = (req, params) => {
26
+ const denied = authorizeRoute(req, { scope: "system:admin" });
27
+ if (denied) return denied;
28
+ try {
29
+ const template = getTeamTemplate(slugParam(params));
30
+ return template ? json(template) : error("team template not found", 404);
31
+ } catch (err) {
32
+ return routeError(err);
33
+ }
34
+ };
35
+
36
+ export const postTeamTemplateRoute: Handler = async (req) => {
37
+ const denied = authorizeRoute(req, { scope: "system:admin" });
38
+ if (denied) return denied;
39
+ const parsed = await parseBody<Record<string, unknown>>(req);
40
+ if (!parsed.ok) return error(parsed.error, parsed.status);
41
+ try {
42
+ return json(saveTeamTemplate(parsed.body ?? {}, updatedBy(req)), 201);
43
+ } catch (err) {
44
+ return routeError(err);
45
+ }
46
+ };
47
+
48
+ export const putTeamTemplateRoute: Handler = async (req, params) => {
49
+ const denied = authorizeRoute(req, { scope: "system:admin" });
50
+ if (denied) return denied;
51
+ const parsed = await parseBody<Record<string, unknown>>(req);
52
+ if (!parsed.ok) return error(parsed.error, parsed.status);
53
+ try {
54
+ return json(saveTeamTemplate({ ...(parsed.body ?? {}), slug: slugParam(params) }, updatedBy(req)));
55
+ } catch (err) {
56
+ return routeError(err);
57
+ }
58
+ };
59
+
60
+ export const deleteTeamTemplateRoute: Handler = (req, params) => {
61
+ const denied = authorizeRoute(req, { scope: "system:admin" });
62
+ if (denied) return denied;
63
+ const slug = slugParam(params);
64
+ return json({ slug, deleted: deleteTeamTemplate(slug, updatedBy(req)) });
65
+ };
@@ -0,0 +1,172 @@
1
+ import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, APPROVAL_MODES, VALID_AGENT_LIFECYCLES } from "agent-relay-sdk";
2
+ import { getTeam, ValidationError } from "../db";
3
+ import { deleteConfig, getConfig, listConfig, setConfig } from "../config-store";
4
+ import { TEAM_TEMPLATE_NAMESPACE } from "../team-template-config";
5
+ import { cleanString, optionalEnum } from "../validation";
6
+ import type { AuthContext } from "./auth-context";
7
+ import { setCallerTeamBrief } from "./team";
8
+ import { spawnAgent, type SpawnAgentInput, type SpawnAgentResult } from "./spawn-agent";
9
+ import type { AgentLifecycle, SpawnApprovalMode, SpawnProvider, TeamTemplate, TeamTemplateCompositionEntry, WorkspaceMode } from "../types";
10
+
11
+ export interface TeamTemplateInstantiateInput {
12
+ slug: string;
13
+ orchestratorId?: unknown;
14
+ cwd?: unknown;
15
+ approvalMode?: unknown;
16
+ workspaceMode?: unknown;
17
+ lifecycle?: unknown;
18
+ prompt?: unknown;
19
+ waitForRegistrationMs?: unknown;
20
+ }
21
+
22
+ export interface TeamTemplateInstantiateResult {
23
+ template: TeamTemplate;
24
+ team: ReturnType<typeof setCallerTeamBrief>["team"];
25
+ spawned: SpawnAgentResult[];
26
+ }
27
+
28
+ function slugify(value: string): string {
29
+ const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
30
+ return slug || "team-template";
31
+ }
32
+
33
+ function templateSlug(input: Record<string, unknown>): string {
34
+ return cleanString(input.slug ?? input.id, "slug", { max: 120 }) ?? slugify(cleanString(input.title, "title", { required: true, max: 240 })!);
35
+ }
36
+
37
+ function nowIso(): string {
38
+ return new Date().toISOString();
39
+ }
40
+
41
+ function withTimestamps(template: TeamTemplate, existing?: TeamTemplate): TeamTemplate {
42
+ const now = nowIso();
43
+ return {
44
+ ...template,
45
+ createdAt: existing?.createdAt ?? template.createdAt ?? now,
46
+ updatedAt: now,
47
+ };
48
+ }
49
+
50
+ export function saveTeamTemplate(input: Record<string, unknown>, updatedBy?: string): TeamTemplate {
51
+ if (input.teamId !== undefined && input.composition !== undefined) {
52
+ throw new ValidationError("teamId capture and manual composition are mutually exclusive");
53
+ }
54
+ if (input.teamId !== undefined) return captureTeamTemplateFromTeam(cleanString(input.teamId, "teamId", { required: true, max: 120 })!, input, updatedBy);
55
+ const slug = templateSlug(input);
56
+ const existing = getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value;
57
+ const template = withTimestamps({
58
+ id: slug,
59
+ slug,
60
+ title: cleanString(input.title, "title", { required: true, max: 240 })!,
61
+ ...(cleanString(input.description, "description", { max: 2000 }) ? { description: cleanString(input.description, "description", { max: 2000 }) } : {}),
62
+ composition: input.composition as TeamTemplateCompositionEntry[],
63
+ ...(cleanString(input.brief, "brief", { max: 16_000 }) ? { brief: cleanString(input.brief, "brief", { max: 16_000 }) } : {}),
64
+ ...(cleanString(input.definitionOfDone, "definitionOfDone", { max: 4000 }) ? { definitionOfDone: cleanString(input.definitionOfDone, "definitionOfDone", { max: 4000 }) } : {}),
65
+ ...(input.planSkeleton !== undefined ? { planSkeleton: input.planSkeleton as TeamTemplate["planSkeleton"] } : {}),
66
+ }, existing);
67
+ return setConfig(TEAM_TEMPLATE_NAMESPACE, template.slug, template, updatedBy).value as TeamTemplate;
68
+ }
69
+
70
+ export function listTeamTemplates(): TeamTemplate[] {
71
+ return listConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE).map((entry) => entry.value);
72
+ }
73
+
74
+ export function getTeamTemplate(slug: string): TeamTemplate | null {
75
+ return getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value ?? null;
76
+ }
77
+
78
+ export function deleteTeamTemplate(slug: string, updatedBy?: string): boolean {
79
+ return deleteConfig(TEAM_TEMPLATE_NAMESPACE, slug, updatedBy);
80
+ }
81
+
82
+ function parseFrozenComposition(raw: string): Array<Record<string, unknown>> {
83
+ try {
84
+ const parsed = JSON.parse(raw) as unknown;
85
+ if (!Array.isArray(parsed)) throw new Error("not array");
86
+ return parsed.filter(isRecord);
87
+ } catch {
88
+ throw new ValidationError("team composition snapshot is invalid JSON");
89
+ }
90
+ }
91
+
92
+ function compositionFromFrozenSnapshot(raw: string): TeamTemplateCompositionEntry[] {
93
+ const grouped = new Map<string, TeamTemplateCompositionEntry>();
94
+ for (const member of parseFrozenComposition(raw)) {
95
+ const provider = cleanString(member.provider, "composition.provider", { max: 40 });
96
+ if (!provider) continue;
97
+ if (!SPAWN_PROVIDERS.includes(provider as SpawnProvider)) throw new ValidationError(`unsupported captured provider: ${provider}`);
98
+ const model = cleanString(member.model, "composition.model", { max: 120 });
99
+ const label = cleanString(member.label ?? member.role, "composition.label", { max: 120 }) ?? provider;
100
+ const key = JSON.stringify({ label, provider, model });
101
+ const existing = grouped.get(key);
102
+ if (existing) existing.count += 1;
103
+ else grouped.set(key, { label, provider: provider as SpawnProvider, ...(model ? { model } : {}), count: 1 });
104
+ }
105
+ const composition = [...grouped.values()];
106
+ if (!composition.length) throw new ValidationError("team composition has no spawnable provider members");
107
+ return composition;
108
+ }
109
+
110
+ export function captureTeamTemplateFromTeam(teamId: string, input: Record<string, unknown> = {}, updatedBy?: string): TeamTemplate {
111
+ const team = getTeam(teamId);
112
+ if (!team) throw new ValidationError(`team ${teamId} not found`);
113
+ if (!team.composition) throw new ValidationError("team has no frozen composition snapshot; dissolve it before capture");
114
+ const title = cleanString(input.title, "title", { max: 240 }) ?? `${team.title ?? team.id} template`;
115
+ const slug = cleanString(input.slug ?? input.id, "slug", { max: 120 }) ?? slugify(title);
116
+ const existing = getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value;
117
+ const template = withTimestamps({
118
+ id: slug,
119
+ slug,
120
+ title,
121
+ ...(cleanString(input.description, "description", { max: 2000 }) ?? team.outcomeSummary ? { description: cleanString(input.description, "description", { max: 2000 }) ?? team.outcomeSummary } : {}),
122
+ composition: compositionFromFrozenSnapshot(team.composition),
123
+ ...(team.brief ? { brief: team.brief } : {}),
124
+ ...(team.definitionOfDone ? { definitionOfDone: team.definitionOfDone } : {}),
125
+ }, existing);
126
+ return setConfig(TEAM_TEMPLATE_NAMESPACE, template.slug, template, updatedBy).value as TeamTemplate;
127
+ }
128
+
129
+ function cleanWait(value: unknown): number {
130
+ if (value === undefined || value === null) return 0;
131
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 30_000) {
132
+ throw new ValidationError("waitForRegistrationMs must be an integer between 0 and 30000");
133
+ }
134
+ return value;
135
+ }
136
+
137
+ function spawnInputForEntry(template: TeamTemplate, entry: TeamTemplateCompositionEntry, index: number, count: number, input: TeamTemplateInstantiateInput): SpawnAgentInput {
138
+ return {
139
+ provider: entry.provider,
140
+ orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { max: 200 }),
141
+ cwd: cleanString(input.cwd, "cwd", { max: 500 }),
142
+ model: entry.model,
143
+ effort: entry.effort,
144
+ approvalMode: optionalEnum(input.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
145
+ workspaceMode: optionalEnum(input.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
146
+ lifecycle: optionalEnum(input.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined,
147
+ profile: entry.profile,
148
+ label: count === 1 ? entry.label : `${entry.label} ${index + 1}`,
149
+ prompt: cleanString(input.prompt, "prompt", { max: 16_000 }) ?? template.brief,
150
+ tags: ["team-template", `template:${template.slug}`],
151
+ requestedVia: "team-template",
152
+ waitForRegistrationMs: cleanWait(input.waitForRegistrationMs),
153
+ };
154
+ }
155
+
156
+ export async function instantiateTeamTemplate(input: TeamTemplateInstantiateInput, ctx: AuthContext): Promise<TeamTemplateInstantiateResult> {
157
+ const slug = cleanString(input.slug, "slug", { required: true, max: 120 })!;
158
+ const template = getTeamTemplate(slug);
159
+ if (!template) throw new ValidationError(`team template ${slug} not found`);
160
+ const teamPayload = setCallerTeamBrief(ctx, {
161
+ title: template.title,
162
+ brief: template.brief,
163
+ definitionOfDone: template.definitionOfDone,
164
+ });
165
+ const spawned: SpawnAgentResult[] = [];
166
+ for (const entry of template.composition) {
167
+ for (let i = 0; i < entry.count; i++) {
168
+ spawned.push(await spawnAgent(spawnInputForEntry(template, entry, i, entry.count, input), ctx));
169
+ }
170
+ }
171
+ return { template, team: teamPayload.team, spawned };
172
+ }
@@ -0,0 +1,100 @@
1
+ import { isRecord, SPAWN_PROVIDERS, VALID_EFFORTS } from "agent-relay-sdk";
2
+ import { ValidationError } from "./db";
3
+ import { cleanEnum, cleanString, cleanStringArray } from "./validation";
4
+ import type { PlanOwnerRef, SpawnProvider, TeamTemplate, TeamTemplateCompositionEntry, TeamTemplatePlanSkeleton } from "./types";
5
+
6
+ export const TEAM_TEMPLATE_NAMESPACE = "team-template";
7
+
8
+ function cleanTeamTemplateSlug(value: unknown, field: string): string {
9
+ const slug = cleanString(value, field, { required: true, max: 120 })!;
10
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(slug)) throw new ValidationError(`${field} must be a portable slug`);
11
+ return slug;
12
+ }
13
+
14
+ function cleanPositiveCount(value: unknown, field: string): number {
15
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 50) throw new ValidationError(`${field} must be an integer between 1 and 50`);
16
+ return value;
17
+ }
18
+
19
+ function optionalEffort(value: unknown, field: string): TeamTemplateCompositionEntry["effort"] {
20
+ if (value === undefined || value === null) return undefined;
21
+ return cleanEnum(value, field, VALID_EFFORTS) as TeamTemplateCompositionEntry["effort"];
22
+ }
23
+
24
+ function optionalStringProp<T extends string>(out: object, key: T, value: unknown, max: number): void {
25
+ const cleaned = cleanString(value, key, { max });
26
+ if (cleaned) (out as unknown as Record<string, unknown>)[key] = cleaned;
27
+ }
28
+
29
+ function validateTeamTemplateComposition(value: unknown): TeamTemplateCompositionEntry[] {
30
+ if (!Array.isArray(value)) throw new ValidationError("composition must be an array");
31
+ if (value.length === 0) throw new ValidationError("composition must contain at least one entry");
32
+ if (value.length > 50) throw new ValidationError("composition can contain at most 50 entries");
33
+ return value.map((item, index) => {
34
+ if (!isRecord(item)) throw new ValidationError(`composition[${index}] must be an object`);
35
+ const out: TeamTemplateCompositionEntry = {
36
+ label: cleanString(item.label, `composition[${index}].label`, { required: true, max: 120 })!,
37
+ provider: cleanEnum(item.provider, `composition[${index}].provider`, SPAWN_PROVIDERS) as SpawnProvider,
38
+ count: cleanPositiveCount(item.count, `composition[${index}].count`),
39
+ };
40
+ optionalStringProp(out, "model", item.model, 120);
41
+ optionalStringProp(out, "profile", item.profile, 120);
42
+ const effort = optionalEffort(item.effort, `composition[${index}].effort`);
43
+ if (effort) out.effort = effort;
44
+ return out;
45
+ });
46
+ }
47
+
48
+ function validatePlanSkeleton(value: unknown): TeamTemplatePlanSkeleton | undefined {
49
+ if (value === undefined || value === null) return undefined;
50
+ if (!isRecord(value)) throw new ValidationError("planSkeleton must be an object");
51
+ if (value.nodes !== undefined && !Array.isArray(value.nodes)) throw new ValidationError("planSkeleton.nodes must be an array");
52
+ if (value.edges !== undefined && !Array.isArray(value.edges)) throw new ValidationError("planSkeleton.edges must be an array");
53
+ const skeleton: TeamTemplatePlanSkeleton = {};
54
+ if (Array.isArray(value.nodes)) skeleton.nodes = value.nodes.map((node, index) => {
55
+ if (!isRecord(node)) throw new ValidationError(`planSkeleton.nodes[${index}] must be an object`);
56
+ const out: NonNullable<TeamTemplatePlanSkeleton["nodes"]>[number] = {
57
+ id: cleanString(node.id, `planSkeleton.nodes[${index}].id`, { required: true, max: 120 })!,
58
+ label: cleanString(node.label, `planSkeleton.nodes[${index}].label`, { required: true, max: 240 })!,
59
+ status: cleanEnum(node.status ?? "pending", `planSkeleton.nodes[${index}].status`, ["pending", "in-progress", "landed", "blocked"] as const),
60
+ statusSource: cleanEnum(node.statusSource ?? "manual", `planSkeleton.nodes[${index}].statusSource`, ["manual", "bound"] as const),
61
+ };
62
+ optionalStringProp(out, "scopeBlurb", node.scopeBlurb, 2000);
63
+ optionalStringProp(out, "lane", node.lane, 120);
64
+ const refs = cleanStringArray(node.blackboardRefs, `planSkeleton.nodes[${index}].blackboardRefs`, { itemMax: 120, maxItems: 100 });
65
+ if (refs?.length) out.blackboardRefs = refs;
66
+ if (isRecord(node.ownerRef)) {
67
+ const ownerRef: PlanOwnerRef = {};
68
+ optionalStringProp(ownerRef, "agentId", node.ownerRef.agentId, 200);
69
+ optionalStringProp(ownerRef, "policyName", node.ownerRef.policyName, 120);
70
+ optionalStringProp(ownerRef, "label", node.ownerRef.label, 120);
71
+ if (Object.keys(ownerRef).length) out.ownerRef = ownerRef;
72
+ }
73
+ return out;
74
+ });
75
+ if (Array.isArray(value.edges)) skeleton.edges = value.edges.map((edge, index) => {
76
+ if (!isRecord(edge)) throw new ValidationError(`planSkeleton.edges[${index}] must be an object`);
77
+ return {
78
+ from: cleanString(edge.from, `planSkeleton.edges[${index}].from`, { required: true, max: 120 })!,
79
+ to: cleanString(edge.to, `planSkeleton.edges[${index}].to`, { required: true, max: 120 })!,
80
+ };
81
+ });
82
+ return skeleton;
83
+ }
84
+
85
+ export function validateTeamTemplate(key: string, value: unknown): TeamTemplate {
86
+ if (!isRecord(value)) throw new ValidationError("team template value must be an object");
87
+ const slug = cleanTeamTemplateSlug(value.slug ?? value.id ?? key, "slug");
88
+ if (slug !== key) throw new ValidationError("slug must match config key");
89
+ const id = cleanTeamTemplateSlug(value.id ?? slug, "id");
90
+ if (id !== slug) throw new ValidationError("id must match slug");
91
+ const out: TeamTemplate = { id, slug, title: cleanString(value.title, "title", { required: true, max: 240 })!, composition: validateTeamTemplateComposition(value.composition) };
92
+ optionalStringProp(out, "description", value.description, 2000);
93
+ optionalStringProp(out, "brief", value.brief, 16_000);
94
+ optionalStringProp(out, "definitionOfDone", value.definitionOfDone, 4000);
95
+ optionalStringProp(out, "createdAt", value.createdAt, 80);
96
+ optionalStringProp(out, "updatedAt", value.updatedAt, 80);
97
+ const planSkeleton = validatePlanSkeleton(value.planSkeleton);
98
+ if (planSkeleton) out.planSkeleton = planSkeleton;
99
+ return out;
100
+ }