agent-relay-server 0.79.0 → 0.81.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 (66) hide show
  1. package/docs/openapi.json +55 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-C-3IcqgU.js → MessageBubble-DcSxvVGQ.js} +2 -2
  4. package/public/assets/{MessageBubble-C-3IcqgU.js.map → MessageBubble-DcSxvVGQ.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-o3u36Del.js → PlanGraphPanel-C_Qm7s4q.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-o3u36Del.js.map → PlanGraphPanel-C_Qm7s4q.js.map} +1 -1
  7. package/public/assets/{activity-L3POfhGa.js → activity-HoO4RRm1.js} +2 -2
  8. package/public/assets/{activity-L3POfhGa.js.map → activity-HoO4RRm1.js.map} +1 -1
  9. package/public/assets/{agents-DAdDtQb6.js → agents-CSYMFVpW.js} +2 -2
  10. package/public/assets/{agents-DAdDtQb6.js.map → agents-CSYMFVpW.js.map} +1 -1
  11. package/public/assets/{automation-kakk3xZ9.js → automation-Bj2AYEw0.js} +2 -2
  12. package/public/assets/{automation-kakk3xZ9.js.map → automation-Bj2AYEw0.js.map} +1 -1
  13. package/public/assets/{chat-DDlRjwGC.js → chat-C410UoF1.js} +2 -2
  14. package/public/assets/{chat-DDlRjwGC.js.map → chat-C410UoF1.js.map} +1 -1
  15. package/public/assets/{formatted-body-impl-CEqSWNRL.js → formatted-body-impl-CzKd4psz.js} +2 -2
  16. package/public/assets/{formatted-body-impl-CEqSWNRL.js.map → formatted-body-impl-CzKd4psz.js.map} +1 -1
  17. package/public/assets/{index-CtGaz-Yg.js → index-DW4YIA7L.js} +5 -5
  18. package/public/assets/{index-CtGaz-Yg.js.map → index-DW4YIA7L.js.map} +1 -1
  19. package/public/assets/{insights-mf5sd483.js → insights-DgGTwAwZ.js} +2 -2
  20. package/public/assets/{insights-mf5sd483.js.map → insights-DgGTwAwZ.js.map} +1 -1
  21. package/public/assets/{maintenance-CruMfiA7.js → maintenance-CmgIgjo4.js} +2 -2
  22. package/public/assets/{maintenance-CruMfiA7.js.map → maintenance-CmgIgjo4.js.map} +1 -1
  23. package/public/assets/{managed-agents-fOhHGzNI.js → managed-agents-DRxAGFUB.js} +2 -2
  24. package/public/assets/{managed-agents-fOhHGzNI.js.map → managed-agents-DRxAGFUB.js.map} +1 -1
  25. package/public/assets/{markdown-preview-impl-g5-PTM1A.js → markdown-preview-impl-DSquGj2d.js} +2 -2
  26. package/public/assets/{markdown-preview-impl-g5-PTM1A.js.map → markdown-preview-impl-DSquGj2d.js.map} +1 -1
  27. package/public/assets/{memory-CRyq2WNC.js → memory-BYq4eEF_.js} +2 -2
  28. package/public/assets/{memory-CRyq2WNC.js.map → memory-BYq4eEF_.js.map} +1 -1
  29. package/public/assets/{messages-BqLp0X3y.js → messages-BZt9N6sy.js} +2 -2
  30. package/public/assets/{messages-BqLp0X3y.js.map → messages-BZt9N6sy.js.map} +1 -1
  31. package/public/assets/{orchestrators-BD4nZcG3.js → orchestrators-DoqpuP2u.js} +2 -2
  32. package/public/assets/{orchestrators-BD4nZcG3.js.map → orchestrators-DoqpuP2u.js.map} +1 -1
  33. package/public/assets/{overview-De-2GN06.js → overview-CtpqcW4U.js} +2 -2
  34. package/public/assets/{overview-De-2GN06.js.map → overview-CtpqcW4U.js.map} +1 -1
  35. package/public/assets/{pairs-DOqjBd0R.js → pairs-XoWsTxhe.js} +2 -2
  36. package/public/assets/{pairs-DOqjBd0R.js.map → pairs-XoWsTxhe.js.map} +1 -1
  37. package/public/assets/{security-DtoRSpIi.js → security-B_C5yJi5.js} +2 -2
  38. package/public/assets/{security-DtoRSpIi.js.map → security-B_C5yJi5.js.map} +1 -1
  39. package/public/assets/{select-esP750zQ.js → select-BBZ8pgCn.js} +2 -2
  40. package/public/assets/{select-esP750zQ.js.map → select-BBZ8pgCn.js.map} +1 -1
  41. package/public/assets/{settings-U8788lYt.js → settings-DNYEfnw6.js} +2 -2
  42. package/public/assets/{settings-U8788lYt.js.map → settings-DNYEfnw6.js.map} +1 -1
  43. package/public/assets/{tasks-CtiwQy9I.js → tasks-Bww7kqYc.js} +2 -2
  44. package/public/assets/{tasks-CtiwQy9I.js.map → tasks-Bww7kqYc.js.map} +1 -1
  45. package/public/assets/teams-C6B038l0.js +2 -0
  46. package/public/assets/teams-C6B038l0.js.map +1 -0
  47. package/public/assets/{terminal-viewer-impl-h9hvvB9S.js → terminal-viewer-impl-CvoK2Byo.js} +2 -2
  48. package/public/assets/{terminal-viewer-impl-h9hvvB9S.js.map → terminal-viewer-impl-CvoK2Byo.js.map} +1 -1
  49. package/public/assets/{work-queue-C-bS6YmI.js → work-queue-w_eT-XFN.js} +2 -2
  50. package/public/assets/{work-queue-C-bS6YmI.js.map → work-queue-w_eT-XFN.js.map} +1 -1
  51. package/public/index.html +1 -1
  52. package/src/db/teams.ts +31 -4
  53. package/src/maintenance/jobs.ts +9 -1
  54. package/src/maintenance/teams.ts +116 -0
  55. package/src/notification-types.ts +2 -0
  56. package/src/routes/index.ts +3 -0
  57. package/src/routes/teams-config.ts +15 -0
  58. package/src/routes/teams.ts +11 -4
  59. package/src/security.ts +3 -1
  60. package/src/services/team.ts +17 -4
  61. package/src/services/transient-agent-reaper.ts +36 -0
  62. package/src/settings/registry.ts +27 -0
  63. package/src/team-idle-tracker.ts +13 -0
  64. package/src/teams-config-store.ts +22 -0
  65. package/public/assets/teams-Cx29VUif.js +0 -2
  66. package/public/assets/teams-Cx29VUif.js.map +0 -1
@@ -1,2 +1,2 @@
1
- import{Hn as e,i as t,lt as n,sn as r,vn as i}from"./lucide-react-CNGB8fo1.js";import{l as a,t as o}from"./store-Cjcd0egy.js";import{U as s,d as c,ut as l}from"./display-DHgdn4vN.js";import{t as u}from"./badge-DGB2nVM0.js";import{t as d}from"./button-D_Y8CEP4.js";import{B as f,K as p,s as m}from"./index-CtGaz-Yg.js";import{r as h,t as g}from"./card-DTLNgk0R.js";var _=e(),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(),t=o(e=>e.agentsById),r=o(e=>e.compose),i=o(e=>e.set),s=o(e=>e.doClaimTask),l=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&&l(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)(n,{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:c(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(n=>(0,_.jsx)(b,{item:n,now:e,agentName:e=>t[e]?c(t[e]):e.slice(-10),onClaim:()=>w(n),onCancel:()=>T(n),onEvents:n.task?()=>E(n):void 0},n.id))]})})]})}function b({item:e,now:n,agentName:a,onClaim:o,onCancel:c,onEvents:f}){let p=l[e.severity]||l.info,m=v[e.status]||`bg-zinc-500/10 text-zinc-400 border-zinc-500/20`;return(0,_.jsx)(g,{className:e.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:e.title}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${p}`,children:e.severity}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${m}`,children:e.status}),e.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`})]}),e.body&&(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground line-clamp-2 mb-2`,children:e.body}),(0,_.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground flex-wrap`,children:[e.owner&&(0,_.jsxs)(`span`,{title:`Owner`,children:[(0,_.jsx)(r,{className:`w-3 h-3 inline mr-1`}),a(e.owner)]}),e.source&&(0,_.jsx)(`span`,{className:`font-mono`,children:e.source}),(0,_.jsxs)(`span`,{title:String(e.updatedAt),children:[(0,_.jsx)(i,{className:`w-3 h-3 inline mr-1`}),s(n,e.updatedAt)]})]})]}),(0,_.jsxs)(`div`,{className:`flex flex-col gap-1 shrink-0`,children:[e.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)(r,{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:c,children:[(0,_.jsx)(t,{className:`w-3.5 h-3.5 mr-1`}),` Cancel`]})]})]})})})}export{y as WorkQueueView};
2
- //# sourceMappingURL=work-queue-C-bS6YmI.js.map
1
+ import{Hn as e,i as t,lt as n,sn as r,vn as i}from"./lucide-react-CNGB8fo1.js";import{l as a,t as o}from"./store-Cjcd0egy.js";import{U as s,d as c,ut as l}from"./display-DHgdn4vN.js";import{t as u}from"./badge-DGB2nVM0.js";import{t as d}from"./button-D_Y8CEP4.js";import{B as f,K as p,s as m}from"./index-DW4YIA7L.js";import{r as h,t as g}from"./card-DTLNgk0R.js";var _=e(),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(),t=o(e=>e.agentsById),r=o(e=>e.compose),i=o(e=>e.set),s=o(e=>e.doClaimTask),l=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&&l(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)(n,{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:c(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(n=>(0,_.jsx)(b,{item:n,now:e,agentName:e=>t[e]?c(t[e]):e.slice(-10),onClaim:()=>w(n),onCancel:()=>T(n),onEvents:n.task?()=>E(n):void 0},n.id))]})})]})}function b({item:e,now:n,agentName:a,onClaim:o,onCancel:c,onEvents:f}){let p=l[e.severity]||l.info,m=v[e.status]||`bg-zinc-500/10 text-zinc-400 border-zinc-500/20`;return(0,_.jsx)(g,{className:e.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:e.title}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${p}`,children:e.severity}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${m}`,children:e.status}),e.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`})]}),e.body&&(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground line-clamp-2 mb-2`,children:e.body}),(0,_.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground flex-wrap`,children:[e.owner&&(0,_.jsxs)(`span`,{title:`Owner`,children:[(0,_.jsx)(r,{className:`w-3 h-3 inline mr-1`}),a(e.owner)]}),e.source&&(0,_.jsx)(`span`,{className:`font-mono`,children:e.source}),(0,_.jsxs)(`span`,{title:String(e.updatedAt),children:[(0,_.jsx)(i,{className:`w-3 h-3 inline mr-1`}),s(n,e.updatedAt)]})]})]}),(0,_.jsxs)(`div`,{className:`flex flex-col gap-1 shrink-0`,children:[e.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)(r,{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:c,children:[(0,_.jsx)(t,{className:`w-3.5 h-3.5 mr-1`}),` Cancel`]})]})]})})})}export{y as WorkQueueView};
2
+ //# sourceMappingURL=work-queue-w_eT-XFN.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"work-queue-C-bS6YmI.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-w_eT-XFN.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-CtGaz-Yg.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-DW4YIA7L.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-CNGB8fo1.js">
package/src/db/teams.ts CHANGED
@@ -3,6 +3,7 @@ import { getAgent, setAgentTeam } from "./agents.ts";
3
3
  import { getDb, ValidationError } from "./connection.ts";
4
4
  import { rowToAgent } from "./mappers.ts";
5
5
  import { unpromotedBlackboardEntries, type UnpromotedBlackboardSummary } from "./project-entries.ts";
6
+ import { isProviderBlocked } from "../provider-state.ts";
6
7
  import type { AgentCard, Team } from "../types";
7
8
 
8
9
  interface TeamRow {
@@ -72,6 +73,11 @@ export interface TeamOutcomeAggregates {
72
73
  recent: TeamOutcomeRecent[];
73
74
  }
74
75
 
76
+ interface TeamBusyState {
77
+ busyMemberIds: string[];
78
+ lastBusyAt?: number;
79
+ }
80
+
75
81
  export function teamId(): string {
76
82
  return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
77
83
  }
@@ -125,11 +131,32 @@ export function getTeamRoster(teamId: string): AgentCard[] {
125
131
  return rows.map(rowToAgent);
126
132
  }
127
133
 
128
- export function countLiveTeamMembers(teamId: string, cutoff: number): number {
134
+ export interface TeamMemberStats {
135
+ total: number;
136
+ live: number;
137
+ }
138
+
139
+ export function getTeamMemberStats(teamId: string, cutoff: number): TeamMemberStats {
129
140
  const row = getDb().query(
130
- "SELECT count(*) AS n FROM agents WHERE team_id = ? AND status NOT IN ('offline') AND last_seen > ?",
131
- ).get(teamId, cutoff) as { n: number };
132
- return row.n;
141
+ "SELECT count(*) AS total, count(CASE WHEN status != 'offline' AND last_seen > ? THEN 1 END) AS live FROM agents WHERE team_id = ?",
142
+ ).get(cutoff, teamId) as { total: number; live: number };
143
+ return { total: row.total, live: row.live };
144
+ }
145
+
146
+ export function countLiveTeamMembers(teamId: string, cutoff: number): number {
147
+ return getTeamMemberStats(teamId, cutoff).live;
148
+ }
149
+
150
+ export function getTeamBusyState(teamId: string, cutoff: number): TeamBusyState {
151
+ const rows = (getDb().query(
152
+ "SELECT * FROM agents WHERE team_id = ? AND status = 'busy' AND last_seen > ? ORDER BY last_seen DESC, id ASC",
153
+ ).all(teamId, cutoff) as any[])
154
+ .map(rowToAgent)
155
+ .filter((agent) => !isProviderBlocked(agent));
156
+ return {
157
+ busyMemberIds: rows.map((row) => row.id),
158
+ ...(rows[0] ? { lastBusyAt: rows[0].lastSeen } : {}),
159
+ };
133
160
  }
134
161
 
135
162
  export function setTeamZeroMembersSince(teamId: string, ts: number): boolean {
@@ -51,7 +51,7 @@ import {
51
51
  } from "./constants";
52
52
  import { emitCommand } from "./events";
53
53
  import { reapOrphanedSessions } from "./orphaned-sessions";
54
- import { reapZeroMemberTeams } from "./teams";
54
+ import { reapZeroMemberTeams, runTeamIdleWatchdog } from "./teams";
55
55
  import { scanWorkspaceConflicts, workspaceGC } from "./workspaces";
56
56
  import type { MaintenanceJobDefinition } from "./types";
57
57
 
@@ -208,6 +208,14 @@ export const definitions: MaintenanceJobDefinition[] = [
208
208
  runOnStart: false,
209
209
  handler: reapZeroMemberTeams,
210
210
  },
211
+ {
212
+ id: "team-idle-watchdog",
213
+ title: "Team idle watchdog",
214
+ description: "Gently ping a team's current coordinator once when the team has had no busy members for the configured threshold.",
215
+ intervalMs: REAP_INTERVAL_MS,
216
+ runOnStart: false,
217
+ handler: runTeamIdleWatchdog,
218
+ },
211
219
  {
212
220
  id: "orchestrator-reaper",
213
221
  title: "Orchestrator reaper",
@@ -2,12 +2,128 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getTeamBusyState,
6
+ getTeamRoster,
5
7
  listActiveTeams,
6
8
  setTeamZeroMembersSince,
7
9
  } from "../db";
8
10
  import { STALE_TTL_MS } from "../config";
11
+ import { agentProviderState } from "../provider-state";
9
12
  import { dissolveTeamById } from "../services/team";
13
+ import { getTeamsConfig } from "../teams-config-store";
10
14
  import { teamReapGraceMs } from "./constants";
15
+ import { notifyTarget } from "./events";
16
+ import { teamIdleTracker } from "../team-idle-tracker";
17
+
18
+ export function resetTeamIdleWatchdogForTests(): void {
19
+ teamIdleTracker.clear();
20
+ }
21
+
22
+ function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
23
+ const roster = getTeamRoster(teamId).map((member) => {
24
+ const providerState = agentProviderState(member);
25
+ return {
26
+ id: member.id,
27
+ name: member.name,
28
+ label: member.label,
29
+ status: member.status,
30
+ ready: member.ready,
31
+ lastSeen: member.lastSeen,
32
+ blocked: providerState?.state === "blocked",
33
+ providerState: providerState ?? undefined,
34
+ };
35
+ });
36
+ return {
37
+ kind: "team.idle",
38
+ teamId,
39
+ coordinatorId,
40
+ thresholdMinutes,
41
+ thresholdMs: thresholdMinutes * 60_000,
42
+ idleForMs,
43
+ firstNoBusyAt,
44
+ roster,
45
+ blockedMembers: roster.filter((member) => member.blocked).map((member) => ({
46
+ id: member.id,
47
+ providerState: member.providerState,
48
+ })),
49
+ };
50
+ }
51
+
52
+ function notifyTeamIdle(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number): string | null {
53
+ const roundedIdleMinutes = Math.max(thresholdMinutes, Math.floor(idleForMs / 60_000));
54
+ const payload = teamIdlePayload(teamId, coordinatorId, thresholdMinutes, idleForMs, firstNoBusyAt);
55
+ return notifyTarget(
56
+ "team.idle",
57
+ { teamId, agentId: coordinatorId },
58
+ coordinatorId,
59
+ "Team idle watchdog",
60
+ `Team \`${teamId}\` has had no busy members for ${roundedIdleMinutes} min. If the work is done, stand down. If not, check the team roster and restart unfinished work.`,
61
+ payload,
62
+ );
63
+ }
64
+
65
+ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string, unknown> {
66
+ const config = getTeamsConfig();
67
+ if (!config.enabled) {
68
+ const cleared = teamIdleTracker.size;
69
+ teamIdleTracker.clear();
70
+ return { enabled: false, clearedTrackedTeams: cleared };
71
+ }
72
+
73
+ const now = input.now ?? Date.now();
74
+ const cutoff = now - STALE_TTL_MS;
75
+ const thresholdMs = config.thresholdMinutes * 60_000;
76
+ const seen = new Set<string>();
77
+ const busyTeamIds: string[] = [];
78
+ const waitingTeamIds: string[] = [];
79
+ const pingedTeamIds: string[] = [];
80
+ const alreadyPingedTeamIds: string[] = [];
81
+ const missingCoordinatorTeamIds: string[] = [];
82
+
83
+ for (const team of listActiveTeams()) {
84
+ seen.add(team.id);
85
+ const busy = getTeamBusyState(team.id, cutoff);
86
+ if (busy.busyMemberIds.length > 0) {
87
+ teamIdleTracker.delete(team.id);
88
+ busyTeamIds.push(team.id);
89
+ continue;
90
+ }
91
+
92
+ const entry = teamIdleTracker.get(team.id) ?? { firstNoBusyAt: now, pinged: false };
93
+ teamIdleTracker.set(team.id, entry);
94
+ const idleForMs = now - entry.firstNoBusyAt;
95
+ if (idleForMs < thresholdMs) {
96
+ waitingTeamIds.push(team.id);
97
+ continue;
98
+ }
99
+
100
+ if (entry.pinged) {
101
+ alreadyPingedTeamIds.push(team.id);
102
+ continue;
103
+ }
104
+ if (!team.coordinatorId) {
105
+ missingCoordinatorTeamIds.push(team.id);
106
+ continue;
107
+ }
108
+
109
+ notifyTeamIdle(team.id, team.coordinatorId, config.thresholdMinutes, idleForMs, entry.firstNoBusyAt);
110
+ entry.pinged = true;
111
+ pingedTeamIds.push(team.id);
112
+ }
113
+
114
+ for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
115
+
116
+ return {
117
+ enabled: true,
118
+ thresholdMinutes: config.thresholdMinutes,
119
+ trackedTeams: teamIdleTracker.size,
120
+ busyTeamIds,
121
+ waitingTeamIds,
122
+ pingedTeamIds,
123
+ alreadyPingedTeamIds,
124
+ missingCoordinatorTeamIds,
125
+ };
126
+ }
11
127
 
12
128
  export function reapZeroMemberTeams(): Record<string, unknown> {
13
129
  const now = Date.now();
@@ -10,6 +10,7 @@ export type NotificationType =
10
10
  | "agent.resume_budget_exhausted"
11
11
  | "agent.transient_land_hold"
12
12
  | "agent.rate_limit_resume"
13
+ | "team.idle"
13
14
  | "plan.bound_agent_exited"
14
15
  | "workspace.steward_task"
15
16
  | "workspace.conflict"
@@ -60,6 +61,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
60
61
  "agent.resume_budget_exhausted": null,
61
62
  "agent.transient_land_hold": null,
62
63
  "agent.rate_limit_resume": 15 * MINUTES,
64
+ "team.idle": null,
63
65
  "plan.bound_agent_exited": null,
64
66
  "workspace.steward_task": null,
65
67
  "workspace.conflict": null,
@@ -7,6 +7,7 @@ import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomati
7
7
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
8
8
  import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
9
9
  import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
10
+ import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
10
11
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
11
12
  import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
12
13
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
@@ -161,6 +162,8 @@ const routes: Route[] = [
161
162
  route("GET", "/api/merges/pauses/:id", getMergePauseById),
162
163
 
163
164
  route("POST", "/api/system/broadcast", postSystemBroadcast),
165
+ route("GET", "/api/teams/config", getTeamsConfigRoute),
166
+ route("PUT", "/api/teams/config", putTeamsConfigRoute),
164
167
  route("GET", "/api/teams", getTeamsRoute),
165
168
  route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
166
169
  route("GET", "/api/teams/me", getMyTeamRoute),
@@ -0,0 +1,15 @@
1
+ import { getTeamsConfig, setTeamsConfig } from "../teams-config-store";
2
+ import { cleanString } from "../validation";
3
+ import { error, json, parseBody, type Handler } from "./_shared";
4
+ import { isRecord } from "agent-relay-sdk";
5
+
6
+ export const getTeamsConfigRoute: Handler = () => {
7
+ return json(getTeamsConfig());
8
+ };
9
+
10
+ export const putTeamsConfigRoute: Handler = async (req) => {
11
+ const parsed = await parseBody<unknown>(req);
12
+ if (!parsed.ok) return error(parsed.error, parsed.status);
13
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
14
+ return json(setTeamsConfig(parsed.body, { updatedBy }));
15
+ };
@@ -1,9 +1,10 @@
1
1
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
2
2
  import { authContextFromRequest } from "../services/auth-context";
3
- import { getTeam, getTeamOutcomeAggregates, listAllTeams, ValidationError } from "../db";
4
- import { dissolveCallerTeam, dissolveTeamById, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
3
+ import { getTeam, getTeamOutcomeAggregates, getPlanByTeam, listAllTeams, ValidationError } from "../db";
4
+ import { dissolveCallerTeam, dissolveTeamById, deriveTeamRuntimeState, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
5
5
  import { cleanString, optionalEnum } from "../validation";
6
6
  import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
7
+ import { STALE_TTL_MS } from "../config";
7
8
  import type { Team } from "../types";
8
9
 
9
10
  const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
@@ -43,8 +44,14 @@ export const getTeamsRoute: Handler = (req) => {
43
44
  if (denied) return denied;
44
45
  try {
45
46
  const status = optionalEnum(new URL(req.url).searchParams.get("status") ?? undefined, "status", TEAM_STATUSES) as Team["status"] | undefined;
46
- const teams = listAllTeams();
47
- return json({ teams: status ? teams.filter((team) => team.status === status) : teams });
47
+ const cutoff = Date.now() - STALE_TTL_MS;
48
+ const all = listAllTeams();
49
+ const filtered = status ? all.filter((team) => team.status === status) : all;
50
+ const teams = filtered.map((team) => {
51
+ const plan = team.status === "active" ? getPlanByTeam(team.id) ?? null : null;
52
+ return { ...team, runtimeState: deriveTeamRuntimeState(team, plan, cutoff) };
53
+ });
54
+ return json({ teams });
48
55
  } catch (err) {
49
56
  return routeError(err);
50
57
  }
package/src/security.ts CHANGED
@@ -194,6 +194,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
194
194
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
195
195
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
196
196
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
197
+ if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
197
198
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
198
199
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
199
200
  if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
@@ -280,6 +281,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
280
281
  if (pathname === "/api/providers/quota-config" || pathname.match(/^\/api\/providers\/[^/]+\/quota-config$/)) {
281
282
  return method === "GET" ? "agent:read" : "settings:write:provider";
282
283
  }
284
+ if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
283
285
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
284
286
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
285
287
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
@@ -304,7 +306,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
304
306
  return "system:admin";
305
307
  }
306
308
 
307
- const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider"]);
309
+ const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider", "teams"]);
308
310
 
309
311
  function requiredSettingsScopeFor(method: string, pathname: string): string | null | undefined {
310
312
  if (pathname === "/api/settings") return method === "GET" ? "settings:read" : null;
@@ -1,10 +1,11 @@
1
1
  import { hasScope } from "agent-relay-sdk/types";
2
2
  import { STALE_TTL_MS } from "../config";
3
- import { claimTeam, countLiveTeamMembers, dissolveTeam, ensureTeam, getAgent, getPlanByTeam, getTeam, getTeamRoster, setTeamBrief } from "../db";
3
+ import { claimTeam, countLiveTeamMembers, dissolveTeam, ensureTeam, getAgent, getPlanByTeam, getTeam, getTeamBusyState, getTeamMemberStats, getTeamRoster, setTeamBrief } from "../db";
4
4
  import { emitRelayEvent } from "../events";
5
5
  import { listIssueRefsForEntity } from "./issue-associations";
6
+ import { getTeamIdleEntry } from "../team-idle-tracker";
6
7
  import type { AuthContext } from "./auth-context";
7
- import type { Team } from "../types";
8
+ import type { Plan, Team, TeamRuntimeState } from "../types";
8
9
 
9
10
  export class TeamAuthError extends Error {}
10
11
  export class TeamNotFoundError extends Error {}
@@ -30,6 +31,16 @@ export interface TeamDissolveServiceInput {
30
31
  landsClean?: boolean;
31
32
  }
32
33
 
34
+ export function deriveTeamRuntimeState(team: Team, plan: Plan | null, cutoff: number): TeamRuntimeState {
35
+ if (team.status === "dissolved") return "dissolved";
36
+ const busy = getTeamBusyState(team.id, cutoff);
37
+ if (busy.busyMemberIds.length > 0) return "busy";
38
+ if (getTeamIdleEntry(team.id)?.pinged) return "stalled";
39
+ const stats = getTeamMemberStats(team.id, cutoff);
40
+ if (stats.total > stats.live && plan && plan.nodes.some((n) => n.status !== "landed")) return "degraded";
41
+ return "idle";
42
+ }
43
+
33
44
  function requireCallerAgentId(ctx: AuthContext): string {
34
45
  if (!ctx.callerAgentId) throw new TeamAuthError("team operations require a single caller agent identity");
35
46
  return ctx.callerAgentId;
@@ -39,12 +50,14 @@ function payloadForAgent(agentId: string, channel?: string): TeamPayload {
39
50
  const agent = getAgent(agentId);
40
51
  const team = agent?.teamId ? getTeam(agent.teamId) : null;
41
52
  if (!team) throw new TeamNotFoundError("caller has no team");
53
+ const plan = getPlanByTeam(team.id, channel) ?? null;
54
+ const runtimeState = deriveTeamRuntimeState(team, plan, Date.now() - STALE_TTL_MS);
42
55
  return {
43
- team,
56
+ team: { ...team, runtimeState },
44
57
  roster: getTeamRoster(team.id),
45
58
  brief: team.brief ?? null,
46
59
  definitionOfDone: team.definitionOfDone ?? null,
47
- plan: getPlanByTeam(team.id, channel) ?? null,
60
+ plan,
48
61
  issues: listIssueRefsForEntity("team", team.id),
49
62
  };
50
63
  }
@@ -4,6 +4,7 @@ import { createActivityEvent, getAgent, listAgents, listWorkspaces } from "../db
4
4
  import { routeNotification } from "../notification-router";
5
5
  import { isProviderBlocked } from "../provider-state";
6
6
  import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
7
+ import type { WorkspaceRecord } from "../types";
7
8
 
8
9
  const envMsOrDefault = (name: string, fallback: number): number => {
9
10
  const v = Number(process.env[name]);
@@ -17,6 +18,34 @@ export function resetTransientTrackerForTests(): void {
17
18
  transientTracker.clear();
18
19
  }
19
20
 
21
+ // How many commits the agent's branch has ahead of its base. Returns null when
22
+ // the check can't run (missing fields, git failure) — callers treat null as
23
+ // "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
24
+ function defaultCommitsAhead(ws: WorkspaceRecord): number | null {
25
+ const base = ws.baseSha ?? ws.baseRef;
26
+ if (!base || !ws.branch || !ws.worktreePath) return null;
27
+ try {
28
+ const proc = Bun.spawnSync(
29
+ ["git", "-C", ws.worktreePath, "rev-list", "--count", `${base}..${ws.branch}`],
30
+ { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
31
+ );
32
+ if (proc.exitCode !== 0) return null;
33
+ const n = parseInt(Buffer.from(proc.stdout).toString("utf8").trim(), 10);
34
+ return Number.isFinite(n) ? n : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ let _commitsAheadFn: (ws: WorkspaceRecord) => number | null = defaultCommitsAhead;
41
+
42
+ export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null): void {
43
+ _commitsAheadFn = fn;
44
+ }
45
+ export function resetCommitsAheadForTests(): void {
46
+ _commitsAheadFn = defaultCommitsAhead;
47
+ }
48
+
20
49
  function transientLandComplete(agentId: string): { complete: boolean; workspaceId?: string; status?: string; reason?: string } {
21
50
  const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
22
51
  if (!isolated.length) return { complete: true, reason: "no-isolated-workspace" };
@@ -27,6 +56,13 @@ function transientLandComplete(agentId: string): { complete: boolean; workspaceI
27
56
  if (live.status === "active" && mergedAt !== undefined && mergedAt > (landReportedAt ?? 0)) {
28
57
  return { complete: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
29
58
  }
59
+ // Fast-path (#629): a zero-commit branch has nothing to preserve — reap immediately
60
+ // instead of holding it pending a land that will never happen. Only fires when the
61
+ // branch is provably empty (count === 0); null (unknown/git error) falls through to
62
+ // the safe #614 hold-pending bias below.
63
+ if (live.status === "active" && _commitsAheadFn(live) === 0) {
64
+ return { complete: true, workspaceId: live.id, status: live.status, reason: "empty-branch" };
65
+ }
30
66
  return { complete: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
31
67
  }
32
68
 
@@ -20,11 +20,14 @@ import {
20
20
  APPROVAL_MODES,
21
21
  errMessage,
22
22
  SPAWN_PROVIDERS,
23
+ TEAM_IDLE_THRESHOLD_MINUTES_MAX,
24
+ TEAM_IDLE_THRESHOLD_MINUTES_MIN,
23
25
  VALID_EFFORTS,
24
26
  type JSONSchema,
25
27
  type SettingDescriptor,
26
28
  type SettingScope,
27
29
  } from "agent-relay-sdk";
30
+ import { DEFAULT_TEAMS_CONFIG } from "agent-relay-sdk/teams-config";
28
31
 
29
32
  /** Fail-closed default scope — a descriptor that omits scope requires admin to
30
33
  * read and write (#385 owns the scope grammar; this layer only declares it). */
@@ -215,6 +218,20 @@ const WORKSPACE_SCHEMA: JSONSchema = {
215
218
  },
216
219
  };
217
220
 
221
+ const TEAMS_SCHEMA: JSONSchema = {
222
+ type: "object",
223
+ additionalProperties: false,
224
+ required: ["enabled", "thresholdMinutes"],
225
+ properties: {
226
+ enabled: { type: "boolean" },
227
+ thresholdMinutes: {
228
+ type: "integer",
229
+ minimum: TEAM_IDLE_THRESHOLD_MINUTES_MIN,
230
+ maximum: TEAM_IDLE_THRESHOLD_MINUTES_MAX,
231
+ },
232
+ },
233
+ };
234
+
218
235
  const LANDING_SCHEMA: JSONSchema = {
219
236
  type: "object",
220
237
  additionalProperties: false,
@@ -347,6 +364,16 @@ function seedBuiltInSettings(): void {
347
364
  display: { group: "Workspace", label: "Landing strategy", description: "Default land strategy for workspace branches.", order: 41 },
348
365
  });
349
366
 
367
+ registerSetting({
368
+ namespace: "teams",
369
+ key: "default",
370
+ schema: TEAMS_SCHEMA,
371
+ defaults: DEFAULT_TEAMS_CONFIG,
372
+ scope: { read: "settings:read:teams", write: "settings:write:teams" },
373
+ tier: "server-runtime",
374
+ display: { group: "Teams", label: "Team idle watchdog", description: "Coordinator nudge when a team has no busy members.", order: 45 },
375
+ });
376
+
350
377
  registerSetting({
351
378
  namespace: "provider",
352
379
  key: "chatCaptureMode",
@@ -0,0 +1,13 @@
1
+ // Singleton in-memory tracker shared by maintenance/teams.ts (writer) and
2
+ // services/team.ts (reader). Lives in its own module to avoid a circular dep:
3
+ // maintenance/teams.ts → services/team (dissolveTeamById) → team-idle-tracker ← maintenance/teams.ts
4
+ export interface TeamIdleEntry {
5
+ firstNoBusyAt: number;
6
+ pinged: boolean;
7
+ }
8
+
9
+ export const teamIdleTracker = new Map<string, TeamIdleEntry>();
10
+
11
+ export function getTeamIdleEntry(teamId: string): TeamIdleEntry | undefined {
12
+ return teamIdleTracker.get(teamId);
13
+ }
@@ -0,0 +1,22 @@
1
+ import { getConfig, setConfig } from "./config-store";
2
+ import { emitConfigChanged } from "./sse";
3
+ import {
4
+ DEFAULT_TEAMS_CONFIG,
5
+ normalizeTeamsConfig,
6
+ type TeamsConfig,
7
+ } from "agent-relay-sdk";
8
+
9
+ const TEAMS_CONFIG_NAMESPACE = "teams";
10
+ const TEAMS_CONFIG_KEY = "default";
11
+
12
+ export function getTeamsConfig(): TeamsConfig {
13
+ const entry = getConfig<TeamsConfig>(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY);
14
+ return entry ? normalizeTeamsConfig(entry.value) : { ...DEFAULT_TEAMS_CONFIG };
15
+ }
16
+
17
+ export function setTeamsConfig(value: unknown, opts: { updatedBy?: string } = {}): TeamsConfig {
18
+ const normalized = normalizeTeamsConfig(value);
19
+ const entry = setConfig(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY, normalized, opts.updatedBy);
20
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
21
+ return normalized;
22
+ }