From 7922eeb8e8ebc16fc812df463e95e6ddab71e161 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 12 Jul 2026 00:46:47 +0200 Subject: [PATCH] chore(agenthub): snapshot pre-existing orphaned WIP (single-instance/mDNS + claimedBy + message-read) as batch baseline --- assets/card_icons/all_tasks.json | 2 +- src/cli/commands/message.ts | 13 ++- src/cli/commands/watch.ts | 45 +++++++++- src/cli/commands/work.ts | 124 +++++++++++++++++--------- src/cli/index.ts | 67 ++++++++++++-- src/core/index.ts | 11 ++- src/core/schema.ts | 1 + src/core/services/activityService.ts | 5 +- src/core/services/messageService.ts | 14 ++- src/core/services/taskService.ts | 4 +- src/discovery.ts | 5 +- src/mcp/server.ts | 127 +++++++++++++++++++-------- src/server/board.ts | 31 +++++-- src/server/events.ts | 1 + src/server/fsWatch.ts | 2 +- src/server/index.ts | 31 +++++-- src/server/mdns.ts | 73 +++++++++++---- src/server/routes.ts | 24 ++--- tests/discovery.test.ts | 13 +++ tests/mdns.test.ts | 43 +++++++++ tests/message-cmd.test.ts | 39 ++++++++ tests/server.test.ts | 17 +++- tests/single-instance.test.ts | 23 ++++- tests/sse.test.ts | 40 +++++++++ tests/taskService.test.ts | 2 + tests/work.test.ts | 35 +++++++- 26 files changed, 645 insertions(+), 147 deletions(-) create mode 100644 tests/mdns.test.ts create mode 100644 tests/message-cmd.test.ts diff --git a/assets/card_icons/all_tasks.json b/assets/card_icons/all_tasks.json index b9f5cd9..17ca186 100644 --- a/assets/card_icons/all_tasks.json +++ b/assets/card_icons/all_tasks.json @@ -1 +1 @@ -{"v":"5.10.2","fr":60,"ip":0,"op":180,"w":500,"h":500,"nm":"09 Core Values","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Main Stroke width - Color Ctrl","parent":10,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[50,50,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"Stroke width","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":16,"ix":1}}]},{"ty":5,"nm":"Highlight width","np":3,"mn":"ADBE Slider Control","ix":2,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":18,"ix":1}}]},{"ty":5,"nm":"Base Color","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0,0,0,1],"ix":1}}]},{"ty":5,"nm":"Highlight","np":3,"mn":"ADBE Color Control","ix":4,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.20000000298,0.800000011921,0.800000011921,1],"ix":1}}]}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Highlight 4","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.999,250,0],"ix":2,"l":2},"a":{"a":0,"k":[249.999,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[21.52,37.274],[-82.027,47.358],[-21.521,-37.274],[82.028,-47.358]],"o":[[-21.52,-37.274],[82.028,-47.359],[21.52,37.274],[-82.027,47.359]],"v":[[-148.523,85.75],[-38.966,-67.491],[148.523,-85.75],[38.966,67.491]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":74,"s":[0]},{"t":134,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":79,"s":[0]},{"t":139,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":74,"s":[0]},{"t":152,"s":[29]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":74,"op":158,"st":74,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Highlight 3","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.999,250,0],"ix":2,"l":2},"a":{"a":0,"k":[249.999,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-21.52,37.274],[-82.027,-47.359],[21.52,-37.274],[82.028,47.359]],"o":[[21.52,-37.274],[82.028,47.358],[-21.521,37.274],[-82.027,-47.358]],"v":[[-148.523,-85.75],[38.966,-67.491],[148.523,85.75],[-38.966,67.491]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":69,"s":[100]},{"t":129,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":74,"s":[100]},{"t":134,"s":[0]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":69,"s":[0]},{"t":147,"s":[-29]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":69,"op":158,"st":69,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Highlight 2","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-42.924,0],[0,94.459],[42.923,0],[0,-94.459]],"o":[[42.923,0],[0,-94.459],[-42.924,0],[0,94.459]],"v":[[0,171.5],[77.932,0],[0,-171.5],[-77.932,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[250,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"t":124,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":69,"s":[0]},{"t":129,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"t":142,"s":[29]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":64,"op":158,"st":64,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Highlight","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.999,250,0],"ix":2,"l":2},"a":{"a":0,"k":[249.999,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[21.52,37.274],[-82.027,47.358],[-21.521,-37.274],[82.028,-47.358]],"o":[[-21.52,-37.274],[82.028,-47.359],[21.52,37.274],[-82.027,47.359]],"v":[[-148.523,85.75],[-38.966,-67.491],[148.523,-85.75],[38.966,67.491]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"t":70,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[0]},{"t":75,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"t":88,"s":[29]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":10,"op":94,"st":10,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Highlight","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.999,250,0],"ix":2,"l":2},"a":{"a":0,"k":[249.999,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-21.52,37.274],[-82.027,-47.359],[21.52,-37.274],[82.028,47.359]],"o":[[21.52,-37.274],[82.028,47.358],[-21.521,37.274],[-82.027,-47.358]],"v":[[-148.523,-85.75],[38.966,-67.491],[148.523,85.75],[-38.966,67.491]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[100]},{"t":65,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"t":70,"s":[0]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"t":83,"s":[-29]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":5,"op":94,"st":5,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Highlight","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-42.924,0],[0,94.459],[42.923,0],[0,-94.459]],"o":[[42.923,0],[0,-94.459],[-42.924,0],[0,94.459]],"v":[[0,171.5],[77.932,0],[0,-171.5],[-77.932,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[250,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":60,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"t":65,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":78,"s":[29]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":94,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Core","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":157,"s":[360]}],"ix":10},"p":{"a":0,"k":[249.999,250,0],"ix":2,"l":2},"a":{"a":0,"k":[249.999,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[21.52,37.274],[-82.027,47.358],[-21.521,-37.274],[82.028,-47.358]],"o":[[-21.52,-37.274],[82.028,-47.359],[21.52,37.274],[-82.027,47.359]],"v":[[-148.523,85.75],[-38.966,-67.491],[148.523,-85.75],[38.966,67.491]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Base Color')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Stroke width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[249.999,250],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Capa 7","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-21.52,37.274],[-82.027,-47.359],[21.52,-37.274],[82.028,47.359]],"o":[[21.52,-37.274],[82.028,47.358],[-21.521,37.274],[-82.027,-47.358]],"v":[[-148.523,-85.75],[38.966,-67.491],[148.523,85.75],[-38.966,67.491]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Base Color')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Stroke width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,250],"ix":2},"a":{"a":0,"k":[249.999,250],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Capa 6","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-42.924,0],[0,94.459],[42.923,0],[0,-94.459]],"o":[[42.923,0],[0,-94.459],[-42.924,0],[0,94.459]],"v":[[0,171.5],[77.932,0],[0,-171.5],[-77.932,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Base Color')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Stroke width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[250,250],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250,250],"ix":2},"a":{"a":0,"k":[250,250],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Capa 5","np":1,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7236,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Heart","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.999,253.5,0],"ix":2,"l":2},"a":{"a":0,"k":[249.999,253.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[-9.024,15.63],[19.973,-19.973],[-19.607,-33.961],[-9.199,-2.464]],"o":[[19.607,-33.961],[-19.974,-19.973],[9.024,15.63],[9.198,-2.464]],"v":[[36.423,12.052],[0.001,-24.504],[-36.422,12.052],[0.001,44.477]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":17.445,"s":[{"i":[[-10.066,17.435],[22.28,-22.28],[-21.872,-37.883],[-10.261,-2.749]],"o":[[21.872,-37.883],[-22.281,-22.28],[10.066,17.435],[10.26,-2.749]],"v":[[40.629,13.445],[0.001,-27.334],[-40.629,13.445],[0.001,49.613]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34.889,"s":[{"i":[[-9.024,15.63],[19.973,-19.973],[-19.607,-33.961],[-9.199,-2.464]],"o":[[19.607,-33.961],[-19.974,-19.973],[9.024,15.63],[9.198,-2.464]],"v":[[36.423,12.052],[0.001,-24.504],[-36.422,12.052],[0.001,44.477]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":52.334,"s":[{"i":[[-10.066,17.435],[22.28,-22.28],[-21.872,-37.883],[-10.261,-2.749]],"o":[[21.872,-37.883],[-22.281,-22.28],[10.066,17.435],[10.26,-2.749]],"v":[[40.629,13.445],[0.001,-27.334],[-40.629,13.445],[0.001,49.613]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":78.5,"s":[{"i":[[-9.024,15.63],[19.973,-19.973],[-19.607,-33.961],[-9.199,-2.464]],"o":[[19.607,-33.961],[-19.974,-19.973],[9.024,15.63],[9.198,-2.464]],"v":[[36.423,12.052],[0.001,-24.504],[-36.422,12.052],[0.001,44.477]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":95.945,"s":[{"i":[[-10.066,17.435],[22.28,-22.28],[-21.872,-37.883],[-10.261,-2.749]],"o":[[21.872,-37.883],[-22.281,-22.28],[10.066,17.435],[10.26,-2.749]],"v":[[40.629,13.445],[0.001,-27.334],[-40.629,13.445],[0.001,49.613]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":113.389,"s":[{"i":[[-9.024,15.63],[19.973,-19.973],[-19.607,-33.961],[-9.199,-2.464]],"o":[[19.607,-33.961],[-19.974,-19.973],[9.024,15.63],[9.198,-2.464]],"v":[[36.423,12.052],[0.001,-24.504],[-36.422,12.052],[0.001,44.477]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":130.834,"s":[{"i":[[-10.066,17.435],[22.28,-22.28],[-21.872,-37.883],[-10.261,-2.749]],"o":[[21.872,-37.883],[-22.281,-22.28],[10.066,17.435],[10.26,-2.749]],"v":[[40.629,13.445],[0.001,-27.334],[-40.629,13.445],[0.001,49.613]],"c":true}]},{"t":157,"s":[{"i":[[-9.024,15.63],[19.973,-19.973],[-19.607,-33.961],[-9.199,-2.464]],"o":[[19.607,-33.961],[-19.974,-19.973],[9.024,15.63],[9.198,-2.464]],"v":[[36.423,12.052],[0.001,-24.504],[-36.422,12.052],[0.001,44.477]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Highlight')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Stroke width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[249.999,247.263],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7236,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":3,"nm":"Zero | troke width - Color Ctrl","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":180,"st":0,"bm":0}],"markers":[]} \ No newline at end of file +{"v":"5.10.2","fr":60,"ip":0,"op":180,"w":500,"h":500,"nm":"16 Behavior","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Main Stroke width - Color Ctrl","parent":7,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[50,50,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"Stroke width","np":3,"mn":"ADBE Slider Control","ix":1,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":16,"ix":1}}]},{"ty":5,"nm":"Highlight width","np":3,"mn":"ADBE Slider Control","ix":2,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":18,"ix":1}}]},{"ty":5,"nm":"Base Color","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0,0,0,1],"ix":1}}]},{"ty":5,"nm":"Highlight","np":3,"mn":"ADBE Color Control","ix":4,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.20000000298,0.800000011921,0.800000011921,1],"ix":1}}]}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":3,"nm":"Rotation","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":43,"s":[0]},{"t":167,"s":[360]}],"ix":10},"p":{"a":0,"k":[242.5,218,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":43,"op":181,"st":-17,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Happyness","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_neg(thisComp.layer('Rotation').transform.rotation);"},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[-13.156,-83.318,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":60,"s":[42.844,-53.318,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":117,"s":[42.844,-53.318,0],"to":[0,0,0],"ti":[0,0,0]},{"t":165,"s":[-13.156,-83.318,0]}],"ix":2,"l":2},"a":{"a":0,"k":[229.242,134.499,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-30.806,0],[0,30.844],[30.806,0],[0,-30.844]],"o":[[30.806,0],[0,-30.844],[-30.806,0],[0,30.844]],"v":[[0,56],[55.93,0],[0,-56],[-55.93,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[229.242,134.499],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-6.922,0],[-3.46,6.002]],"o":[[3.461,6.002],[6.922,0],[0,0]],"v":[[-16.779,-4.85],[0,4.85],[16.779,-4.85]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[229.238,152.049],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[212.465,121.066],[212.459,121.066]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[246.023,121.066],[246.017,121.066]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7236,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Sadness","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_neg(thisComp.layer('Rotation').transform.rotation);"},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":27,"s":[-108.269,32.732,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":67,"s":[-29.269,66.732,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":124,"s":[-29.269,66.732,0],"to":[0,0,0],"ti":[0,0,0]},{"t":172,"s":[-108.269,32.732,0]}],"ix":2,"l":2},"a":{"a":0,"k":[134.431,250.699,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-30.806,0],[0,30.844],[30.806,0],[0,-30.844]],"o":[[30.806,0],[0,-30.844],[-30.806,0],[0,30.844]],"v":[[-0.001,56],[55.93,0],[-0.001,-56],[-55.931,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.20000001496,0.800000059838,0.800000059838,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[134.431,250.699],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-6.922,0],[-3.46,-6.002]],"o":[[3.461,-6.002],[6.923,0],[0,0]],"v":[[-16.779,4.849],[0,-4.85],[16.779,4.849]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[134.428,264.537],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[117.654,237.266],[117.648,237.266]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[151.213,237.266],[151.207,237.266]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7236,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Mask","parent":2,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-63.963,-28.168,0],"ix":2,"l":2},"a":{"a":0,"k":[186.037,153.832,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-30.806,0],[0,30.844],[30.806,0],[0,-30.844]],"o":[[30.806,0],[0,-30.844],[-30.806,0],[0,30.844]],"v":[[0,56],[55.93,0],[0,-56],[-55.93,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[229.242,134.499],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[236.844,98.682],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":60,"s":[292.844,128.682],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":117,"s":[292.844,128.682],"to":[0,0],"ti":[0,0]},{"t":165,"s":[236.844,98.682]}],"ix":2},"a":{"a":0,"k":[229.242,134.499],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6,"x":"var $bm_rt;\n$bm_rt = $bm_neg(thisComp.layer('Rotation').transform.rotation);"},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Happyness 2","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-30.806,0],[0,30.844],[30.806,0],[0,-30.844]],"o":[[30.806,0],[0,-30.844],[-30.806,0],[0,30.844]],"v":[[-0.001,56],[55.93,0],[-0.001,-56],[-55.931,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[134.431,250.699],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":27,"s":[135.231,208.982],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":67,"s":[214.231,242.982],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":124,"s":[214.231,242.982],"to":[0,0],"ti":[0,0]},{"t":172,"s":[135.231,208.982]}],"ix":2},"a":{"a":0,"k":[134.431,250.699],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6,"x":"var $bm_rt;\n$bm_rt = $bm_neg(thisComp.layer('Rotation').transform.rotation);"},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Sadness 2","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7236,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Person","tt":2,"tp":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.69],"y":[1]},"o":{"x":[0.31],"y":[0]},"t":7.719,"s":[0]},{"i":{"x":[0.69],"y":[1]},"o":{"x":[0.31],"y":[0]},"t":42,"s":[-10]},{"i":{"x":[0.69],"y":[1]},"o":{"x":[0.31],"y":[0]},"t":71,"s":[10]},{"i":{"x":[0.69],"y":[1]},"o":{"x":[0.31],"y":[0]},"t":103,"s":[-6]},{"i":{"x":[0.69],"y":[1]},"o":{"x":[0.31],"y":[0]},"t":130,"s":[3]},{"t":159,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[270.75,259.923,0],"to":[0,1.667,0],"ti":[0,5.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":33.961,"s":[270.75,269.923,0],"to":[0,-5.833,0],"ti":[0,0.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":63.133,"s":[270.75,224.923,0],"to":[0,-0.833,0],"ti":[0,-2.5,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":95.867,"s":[270.75,264.923,0],"to":[0,2.5,0],"ti":[0,0.833,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":121.588,"s":[270.75,239.923,0],"to":[0,-0.833,0],"ti":[0,-3.333,0]},{"t":151.28125,"s":[270.75,259.923,0]}],"ix":2,"l":2},"a":{"a":0,"k":[249.999,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[18.427,25.021],[0,30.419],[-69.308,4.771],[0,-85.507],[-13.032,-13.153],[9.989,-5.82],[-5.416,-20.399],[0,0],[4.676,-5.441],[12.238,-12.244],[14.65,0],[0,-16.298],[0,0]],"o":[[0,0],[0,-29.647],[-17.533,-23.807],[0,-87.434],[69.283,-4.739],[0,22.966],[9.897,9.987],[-11.874,6.919],[0,0],[4.405,5.818],[-3.282,12.364],[-4.87,4.872],[-38.965,0],[0,0],[0,0]],"v":[[-112.497,173.675],[-112.497,114.662],[-135.46,54.696],[-162.815,-28.72],[-24.236,-168.936],[114.27,-49.693],[152.918,-3.595],[150.293,23.853],[132.193,55.436],[126.01,66.334],[124.495,84.861],[121.598,120.812],[93.465,129.149],[46.792,159.702],[46.792,173.675]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tr","p":{"a":0,"k":[251.813,247.825],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[94,94],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Base Color')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":16,"ix":5,"x":"var $bm_rt;\n$bm_rt = thisComp.layer('Main Stroke width - Color Ctrl').effect('Stroke width')('Slider');"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false}],"ip":0,"op":7236,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":3,"nm":"Zero | troke width - Color Ctrl","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":180,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/src/cli/commands/message.ts b/src/cli/commands/message.ts index a06627f..f7bfe82 100644 --- a/src/cli/commands/message.ts +++ b/src/cli/commands/message.ts @@ -1,4 +1,4 @@ -import { createMessage, listInbox } from '../../core/services/messageService.js'; +import { createMessage, listInbox, markMessageRead } from '../../core/services/messageService.js'; export function messageSend( cwd: string, @@ -19,3 +19,14 @@ export function inboxList(cwd: string, opts: { agent: string; unreadOnly?: boole console.log(`${flag} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); } } + +export function messageRead(cwd: string, id: string): void { + const m = markMessageRead(cwd, id); + console.log(`AgentHub: Message read ${m.id} (${m.from} → ${m.to})`); +} + +export function inboxMarkRead(cwd: string, opts: { agent: string; unreadOnly?: boolean }): void { + const msgs = listInbox(cwd, opts.agent, { unreadOnly: opts.unreadOnly }); + for (const m of msgs) markMessageRead(cwd, m.id); + console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${opts.agent}`); +} diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index dddddd8..3ed5ea0 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -15,6 +15,7 @@ */ import type { AgentHubEvent } from '../../server/events.js'; +import { messageRecipientAliases } from '../../core/services/messageService.js'; // Re-export so tests can import type + helpers from one place. export type { AgentHubEvent } from '../../server/events.js'; @@ -134,6 +135,36 @@ async function fetchReviewTasks(serverUrl: string): Promise { } } +/** Fetch unread messages currently addressed to an agent or its role alias. */ +async function fetchUnreadMessages(serverUrl: string, agent: string): Promise { + try { + const res = await fetch(new URL(`/messages?agent=${encodeURIComponent(agent)}&unread=1`, serverUrl).toString()); + if (!res.ok) return []; + const messages = (await res.json()) as Array<{ + id: string; + from?: string; + to?: string; + status?: string; + }>; + return messages.map((m) => ({ + type: 'message', + action: 'created', + id: m.id, + title: `${m.from ?? ''} → ${m.to ?? ''}`, + status: m.status ?? 'unread', + assignedTo: m.to, + })); + } catch { + return []; + } +} + +function isMessageFor(event: AgentHubEvent, agent: string): boolean { + if (event.type !== 'message' || event.action !== 'created') return false; + const to = event.assignedTo; + return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase()); +} + /** * Connect to the AgentHub server's SSE endpoint and stream events to stdout. * @@ -148,7 +179,7 @@ async function fetchReviewTasks(serverUrl: string): Promise { */ export async function watchEvents( serverUrl: string, - options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {}, + options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {}, ): Promise { const url = new URL('/events', serverUrl); // Pass role to the server for an additional server-side filter (saves @@ -192,6 +223,14 @@ export async function watchEvents( return; } } + if (options.awaitMessage && !options.newOnly) { + const pending = await fetchUnreadMessages(serverUrl, options.awaitMessage); + if (pending.length > 0) { + for (const ev of pending) console.log(formatEvent(ev)); + await reader.cancel(); + return; + } + } while (true) { let done: boolean; @@ -230,6 +269,10 @@ export async function watchEvents( await reader.cancel(); return; } + if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) { + await reader.cancel(); + return; + } } } } diff --git a/src/cli/commands/work.ts b/src/cli/commands/work.ts index a83a58a..9dbf768 100644 --- a/src/cli/commands/work.ts +++ b/src/cli/commands/work.ts @@ -1,5 +1,6 @@ import { parseSSEBuffer } from './watch.js'; import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js'; +import { discoverServer as discoverHubServer } from '../../discovery.js'; /** * `agenthub work --agent --role ` — the auto-claim primitive @@ -13,7 +14,13 @@ import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentCont * up automatically without a human prompt. Best run in the background so the * wait doesn't tie up the foreground. */ -export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Promise { +interface WorkAgentContext extends AgentContext { + timeoutSec?: number; + discoverServer?: (timeoutMs?: number) => Promise; + reconnectBackoffMs?: number[]; +} + +export async function workAgent(ctx: WorkAgentContext): Promise { await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); // Already-waiting task? @@ -34,16 +41,30 @@ export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Pr await waitAndClaim(ctx); } -function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise { - const serverUrl = ctx.serverUrl as string; +function remainingMs(deadline: number | undefined): number { + return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now()); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function resolveReconnectUrl(ctx: WorkAgentContext, currentUrl: string): Promise { + if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER; + const discovered = await (ctx.discoverServer ?? discoverHubServer)(2000); + return discovered || currentUrl; +} + +function waitAndClaim(ctx: WorkAgentContext): Promise { + let serverUrl = ctx.serverUrl as string; return new Promise((resolve) => { - const controller = new AbortController(); let settled = false; + let controller: AbortController | undefined; const finish = () => { if (settled) return; settled = true; try { - controller.abort(); + controller?.abort(); } catch { /* already aborted */ } @@ -56,10 +77,14 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise => { + ctx.serverUrl = serverUrl; const f = await findAddressedOpenTask(ctx); if (!f) return false; if (timer) clearTimeout(timer); @@ -68,47 +93,64 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise { - if (!res.body) { - if (timer) clearTimeout(timer); - finish(); - return; - } - // Close the gap: a task may have appeared between the initial check and - // this subscription — check once more now that we're listening. - if (await tryClaim()) return; + const waitLoop = async () => { + while (!settled && remainingMs(deadline) > 0) { + controller = new AbortController(); + try { + ctx.serverUrl = serverUrl; + const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } }); + if (!res.body) throw new Error('SSE response has no body'); - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - while (!settled) { - let done: boolean; - let value: Uint8Array | undefined; - try { - ({ done, value } = await reader.read()); - } catch { - break; // aborted or connection closed - } - if (done) break; - if (value) buffer += decoder.decode(value, { stream: true }); + // Close the gap: a task may have appeared between the initial check and + // this subscription — check once more now that we're listening. + if (await tryClaim()) return; - const { events, remaining } = parseSSEBuffer(buffer); - buffer = remaining; - // Any task event may mean a task addressed to us just opened/reopened. - if (events.some((e) => e.type === 'task')) { - if (await tryClaim()) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + reconnectAttempt = 0; + while (!settled) { + let done: boolean; + let value: Uint8Array | undefined; + try { + ({ done, value } = await reader.read()); + } catch { + break; // aborted or connection closed + } + if (done) break; + if (value) buffer += decoder.decode(value, { stream: true }); + + const { events, remaining } = parseSSEBuffer(buffer); + buffer = remaining; + // Any task event may mean a task addressed to us just opened/reopened. + if (events.some((e) => e.type === 'task')) { + if (await tryClaim()) return; + } } + } catch (err: unknown) { + if (settled || (err instanceof Error && err.name === 'AbortError')) return; } - if (timer) clearTimeout(timer); - finish(); - }) - .catch((err: unknown) => { - if (!(err instanceof Error && err.name === 'AbortError')) { - console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`); + if (settled || remainingMs(deadline) <= 0) break; + serverUrl = await resolveReconnectUrl(ctx, serverUrl); + ctx.serverUrl = serverUrl; + const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000; + reconnectAttempt += 1; + const delay = deadline === undefined ? backoff : Math.min(backoff, remainingMs(deadline)); + if (delay > 0) await sleep(delay); + } + if (!settled) { + if (timer) { + clearTimeout(timer); + console.log(`AgentHub: no task for ${ctx.agent} after ${ctx.timeoutSec}s — exiting.`); } - if (timer) clearTimeout(timer); finish(); - }); + } + }; + + waitLoop().catch((err: unknown) => { + console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`); + if (timer) clearTimeout(timer); + finish(); + }); }); } diff --git a/src/cli/index.ts b/src/cli/index.ts index a09ee3c..f5d9f30 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,7 +5,7 @@ import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './co import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { decisionCreate, decisionList } from './commands/decision.js'; -import { messageSend, inboxList } from './commands/message.js'; +import { messageSend, inboxList, messageRead, inboxMarkRead } from './commands/message.js'; import { agentSetup, hookContext } from './commands/agentSetup.js'; import { syncOrgFromFile } from '../core/services/orgService.js'; import { delegate } from './commands/delegate.js'; @@ -457,8 +457,44 @@ export function createProgram(cwd: string): Command { program.addCommand(decisionCmd); // ─── messaging ─────────────────────────────────────────────────────────── - program - .command('message ') + const messageCmd = new Command('message') + .description('Send and manage direct messages') + .argument('[to]', 'Recipient agent/role') + .argument('[text]', 'Message text') + .option('--from ', 'Sender agent name') + .option('--task ', 'Related task ID') + .action(async (to: string | undefined, text: string | undefined, options: { from?: string; task?: string }) => { + if (!to || !text || !options.from) { + messageCmd.help({ error: true }); + return; + } + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + const payload = { from: options.from, to, text, taskId: options.task }; + if (serverUrl) { + await runRemote(serverUrl, async () => { + const m = await remoteClient.sendMessage(serverUrl, payload); + console.log(`AgentHub: Message sent ${m.id} (${m.from} → ${m.to})`); + }); + } else { + messageSend(projectCwd, payload); + } + }); + messageCmd + .command('read ') + .description('Mark a message as read') + .action(async (id: string) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (serverUrl) { + await runRemote(serverUrl, async () => { + const m = await remoteClient.markMessageRead(serverUrl, id); + console.log(`AgentHub: Message read ${m.id} (${m.from} → ${m.to})`); + }); + } else { + messageRead(projectCwd, id); + } + }); + messageCmd + .command('send ') .description('Send a direct message to another agent') .requiredOption('--from ', 'Sender agent name') .option('--task ', 'Related task ID') @@ -474,14 +510,26 @@ export function createProgram(cwd: string): Command { messageSend(projectCwd, payload); } }); + program.addCommand(messageCmd); program .command('inbox') .description('Read messages addressed to an agent') .requiredOption('--agent ', 'Agent whose inbox to read') .option('--unread', 'Only unread messages') - .action(async (options: { agent: string; unread?: boolean }) => { + .option('--mark-read', 'Mark listed messages as read') + .option('--wait', 'Wait until a new unread message arrives for this agent') + .action(async (options: { agent: string; unread?: boolean; markRead?: boolean; wait?: boolean }) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (options.wait) { + if (!serverUrl) { + console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0'); + process.exit(1); + return; + } + await watchEvents(serverUrl, { awaitMessage: options.agent, newOnly: true }); + return; + } if (serverUrl) { await runRemote(serverUrl, async () => { const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread); @@ -489,9 +537,14 @@ export function createProgram(cwd: string): Command { for (const m of msgs) { console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); } + if (options.markRead) { + for (const m of msgs) await remoteClient.markMessageRead(serverUrl, m.id); + console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${options.agent}`); + } }); } else { - inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread }); + if (options.markRead) inboxMarkRead(projectCwd, { agent: options.agent, unreadOnly: !!options.unread }); + else inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread }); } }); @@ -639,7 +692,8 @@ export function createProgram(cwd: string): Command { .option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)') .option('--role ', 'Client-side role filter (only show events for this role)') .option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier') - .option('--new-only', 'With --await-review: fire only on NEW submissions, ignore tasks already in review on connect (re-armable without spinning)') + .option('--await-message ', 'Exit when an unread message arrives for agent/role; architect message notifier') + .option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)') .action(async (options) => { const { serverUrl } = await resolveContext(program, cwd); if (!serverUrl) { @@ -651,6 +705,7 @@ export function createProgram(cwd: string): Command { once: options.once as boolean | undefined, role: options.role as string | undefined, awaitReview: options.awaitReview as boolean | undefined, + awaitMessage: options.awaitMessage as string | undefined, newOnly: options.newOnly as boolean | undefined, }); }); diff --git a/src/core/index.ts b/src/core/index.ts index 705db51..ab4c49a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -14,6 +14,7 @@ export interface IndexEntry { status?: string; role?: string; assignedTo?: string; + claimedBy?: string; reviewer?: string; tags?: string; // Handoff-specific routing fields @@ -46,6 +47,7 @@ export class Index { status TEXT, role TEXT, assignedTo TEXT, + claimedBy TEXT, reviewer TEXT, tags TEXT, fromRole TEXT, @@ -62,7 +64,7 @@ export class Index { const existingCols = new Set( (this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), ); - for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { + for (const col of ['claimedBy', 'reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) { if (!existingCols.has(col)) { this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); } @@ -74,6 +76,7 @@ export class Index { status: null, role: null, assignedTo: null, + claimedBy: null, reviewer: null, tags: null, fromRole: null, @@ -86,12 +89,12 @@ export class Index { }; const insert = this.db.prepare(` - INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) - VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) + INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, claimedBy, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks) + VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @claimedBy, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks) ON CONFLICT(id) DO UPDATE SET type=@type, title=@title, content=@content, filePath=@filePath, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, - assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags, + assignedTo=@assignedTo, claimedBy=@claimedBy, reviewer=@reviewer, tags=@tags, fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent, taskId=@taskId, relatedTasks=@relatedTasks `); diff --git a/src/core/schema.ts b/src/core/schema.ts index 3c35056..e74cc1d 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -15,6 +15,7 @@ export const TaskSchema = z.object({ priority: Priority.default('medium'), role: Role.optional(), assignedTo: z.string().optional(), + claimedBy: z.string().optional(), reviewer: z.string().optional(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), diff --git a/src/core/services/activityService.ts b/src/core/services/activityService.ts index b4f4773..9aad730 100644 --- a/src/core/services/activityService.ts +++ b/src/core/services/activityService.ts @@ -100,7 +100,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] { let summary: string; switch (task.status) { case 'in_progress': - summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed'; + summary = task.claimedBy ? `Claimed by ${task.claimedBy}` : task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed'; break; case 'review': summary = 'Submitted for review'; @@ -117,6 +117,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] { const meta: Record = { status: task.status }; if (task.assignedTo) meta.assignedTo = task.assignedTo; + if (task.claimedBy) meta.claimedBy = task.claimedBy; if (task.doneBy) meta.by = task.doneBy; if (task.doneTokens != null) meta.tokens = task.doneTokens; if (task.doneDuration != null) meta.duration = task.doneDuration; @@ -124,7 +125,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] { items.push({ at: task.updatedAt, kind: 'status', - actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown', + actor: task.doneBy ?? task.claimedBy ?? task.assignedTo ?? task.role ?? 'unknown', summary, meta, }); diff --git a/src/core/services/messageService.ts b/src/core/services/messageService.ts index c8aff85..6fe23bf 100644 --- a/src/core/services/messageService.ts +++ b/src/core/services/messageService.ts @@ -49,6 +49,17 @@ export function createMessage(cwd: string, options: Partial = {}): Mess return record; } +/** Agent aliases that should see the same inbox. Keep deliberately small. */ +export function messageRecipientAliases(agent: string): Set { + const key = agent.toLowerCase(); + const aliases = new Set([agent, key]); + if (key === 'architect' || key === 'claude') { + aliases.add('architect'); + aliases.add('claude'); + } + return aliases; +} + export interface InboxMessage { id: string; from: string; @@ -64,8 +75,9 @@ export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boole const index = new Index(cwd); const all = index.list('message'); index.close(); + const recipients = messageRecipientAliases(agent); return all - .filter((m) => m.toAgent === agent) + .filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase())) .filter((m) => !opts.unreadOnly || m.status === 'unread') .map((m) => ({ id: m.id, diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index 1495a51..43e64cd 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -17,6 +17,7 @@ export function createTask(cwd: string, options: Partial = {}): Task { priority: options.priority ?? 'medium', role: options.role, assignedTo: options.assignedTo, + claimedBy: options.claimedBy, reviewer: options.reviewer, createdAt: now, updatedAt: now, @@ -59,7 +60,7 @@ export function updateTask(cwd: string, id: string, patch: Partial): Task } export function claimTask(cwd: string, id: string, agentName: string): Task { - return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName }); + return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName }); } export function doneTask( @@ -141,6 +142,7 @@ function toIndexEntry(task: Task, filePath: string) { status: task.status, role: task.role, assignedTo: task.assignedTo, + claimedBy: task.claimedBy, reviewer: task.reviewer, tags: JSON.stringify(task.tags), }; diff --git a/src/discovery.ts b/src/discovery.ts index fdce6bc..1e8500c 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -22,17 +22,18 @@ export function resolveAdvertiseUrl(host: string, port: number): string { return `http://${advertiseHost}:${port}`; } -export function startDiscoveryBroadcaster(serverUrl: string, options?: { port?: number; intervalMs?: number }) { +export function startDiscoveryBroadcaster(getServerUrl: string | (() => string), options?: { port?: number; intervalMs?: number }) { const port = options?.port ?? DISCOVERY_PORT; const intervalMs = options?.intervalMs ?? 2000; const socket = dgram.createSocket('udp4'); - const message = Buffer.from(`${DISCOVERY_PREFIX}${serverUrl}`); + const resolveUrl = typeof getServerUrl === 'function' ? getServerUrl : () => getServerUrl; socket.on('error', () => { // Discovery is best-effort; ignore network errors. }); const send = () => { + const message = Buffer.from(`${DISCOVERY_PREFIX}${resolveUrl()}`); try { socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST); } catch { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e797f04..09e7100 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -21,6 +21,7 @@ import { createMessage, listInbox, markMessageRead } from '../core/services/mess import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { createDecision } from '../core/services/decisionService.js'; import { getStatus } from '../core/services/statusService.js'; +import { discoverServer as discoverHubServer } from '../discovery.js'; /** * AgentHub MCP server (TSK-0030). @@ -51,44 +52,87 @@ function asText(value: unknown) { return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] }; } +function remainingMs(deadline: number | undefined): number { + return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now()); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function resolveReconnectUrl(currentUrl: string): Promise { + if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER; + const discovered = await discoverHubServer(2000); + return discovered || currentUrl; +} + /** Block on the SSE stream until findClaim() returns a task, or timeout. */ -function waitForTask(serverUrl: string, findClaim: () => Promise, timeoutSec: number): Promise { +function waitForTask( + serverUrl: string, + findClaim: (serverUrl: string) => Promise, + timeoutSec: number, +): Promise { return new Promise((resolve) => { - const controller = new AbortController(); let settled = false; + let controller: AbortController | undefined; + let currentUrl = serverUrl; + const deadline = Date.now() + Math.max(1, timeoutSec) * 1000; + const backoffs = [2000, 5000, 10000]; + let reconnectAttempt = 0; const finish = (v: T | null) => { if (settled) return; settled = true; - try { controller.abort(); } catch { /* already */ } + try { controller?.abort(); } catch { /* already */ } resolve(v); }; const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000); - fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } }) - .then(async (res) => { - if (!res.body) { clearTimeout(timer); finish(null); return; } - // Close the gap: a task may have arrived between the initial check and now. - const early = await findClaim(); - if (early) { clearTimeout(timer); finish(early); return; } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - while (!settled) { - let done: boolean; let value: Uint8Array | undefined; - try { ({ done, value } = await reader.read()); } catch { break; } - if (done) break; - if (value) buffer += decoder.decode(value, { stream: true }); - const { events, remaining } = parseSSEBuffer(buffer); - buffer = remaining; - // Wake on a new task OR a new message addressed to the agent. - if (events.some((e) => e.type === 'task' || e.type === 'message')) { - const claimed = await findClaim(); - if (claimed) { clearTimeout(timer); finish(claimed); return; } + const waitLoop = async () => { + while (!settled && remainingMs(deadline) > 0) { + controller = new AbortController(); + try { + const res = await fetch(new URL('/events', currentUrl).toString(), { + signal: controller.signal, + headers: { Accept: 'text/event-stream' }, + }); + if (!res.body) throw new Error('SSE response has no body'); + // Close the gap: a task may have arrived between the initial check and now. + const early = await findClaim(currentUrl); + if (early) { clearTimeout(timer); finish(early); return; } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + reconnectAttempt = 0; + while (!settled) { + let done: boolean; let value: Uint8Array | undefined; + try { ({ done, value } = await reader.read()); } catch { break; } + if (done) break; + if (value) buffer += decoder.decode(value, { stream: true }); + const { events, remaining } = parseSSEBuffer(buffer); + buffer = remaining; + // Wake on a new task OR a new message addressed to the agent. + if (events.some((e) => e.type === 'task' || e.type === 'message')) { + const claimed = await findClaim(currentUrl); + if (claimed) { clearTimeout(timer); finish(claimed); return; } + } } + } catch (err: unknown) { + if (settled || (err instanceof Error && err.name === 'AbortError')) return; } - clearTimeout(timer); finish(null); - }) - .catch(() => { clearTimeout(timer); finish(null); }); + if (settled || remainingMs(deadline) <= 0) break; + currentUrl = await resolveReconnectUrl(currentUrl); + const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000; + reconnectAttempt += 1; + await sleep(Math.min(backoff, remainingMs(deadline))); + } + clearTimeout(timer); + finish(null); + }; + + waitLoop().catch(() => { + clearTimeout(timer); + finish(null); + }); }); } @@ -110,37 +154,44 @@ export async function startMcpServer(cwd: string): Promise { async ({ agent, role, timeoutSec }) => { const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' }; const reviewer = isReviewerRole(ctx.role); + const useServerUrl = (nextServerUrl?: string) => { + if (nextServerUrl) ctx.serverUrl = nextServerUrl; + return ctx.serverUrl!; + }; // Fetch + mark-read the agent's unread messages, so the work loop surfaces // them once and doesn't spin on the same message. - const drainInbox = async () => { - const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true }); + const drainInbox = async (nextServerUrl?: string) => { + const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl; + const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true }); for (const m of msgs) { - try { if (remote) await remoteClient.markMessageRead(serverUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ } + try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ } } return msgs; }; - const findWork = async () => { + const findWork = async (nextServerUrl?: string) => { + if (nextServerUrl) useServerUrl(nextServerUrl); const found = await findAddressedOpenTask(ctx); if (found) { - if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent); + if (remote) await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent); else claimTask(root, found.task.id, agent); - const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id); + const detail = remote ? await remoteClient.getTask(ctx.serverUrl!, found.task.id) : getTask(root, found.task.id); const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id); let handoff: unknown = null; if (hofEntry) { - try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ } + try { handoff = remote ? await remoteClient.getHandoff(ctx.serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ } } - return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox() }; + return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox(ctx.serverUrl) }; } - const messages = await drainInbox(); + const messages = await drainInbox(ctx.serverUrl); if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' }; return null; }; // Architect/reviewer variant: wake on tasks submitted to review (not on // tasks addressed to you). Returns the pending review set — never claims. - const findReview = async () => { + const findReview = async (nextServerUrl?: string) => { + if (nextServerUrl) useServerUrl(nextServerUrl); const reviews = await listReviewTasks(ctx); - const messages = await drainInbox(); + const messages = await drainInbox(ctx.serverUrl); if (reviews.length) { return { reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })), @@ -152,7 +203,7 @@ export async function startMcpServer(cwd: string): Promise { return null; }; - const finder: () => Promise | null> = reviewer ? findReview : findWork; + const finder: (nextServerUrl?: string) => Promise | null> = reviewer ? findReview : findWork; // Self-perpetuating loop: every response reminds the agent to relaunch // agenthub_work, so a finished task/message never leaves it dormant. diff --git a/src/server/board.ts b/src/server/board.ts index 52360a8..c7faa3b 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -1000,7 +1000,7 @@ ${columnSkeleton()} return compactDuration(Date.now() - t) + ' ago'; } async function getJSON(path) { - var res = await fetch(path, { headers: { accept: 'application/json' } }); + var res = await fetch(path, { cache: 'no-store', headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(path + ' -> ' + res.status); return res.json(); } @@ -1324,6 +1324,8 @@ ${columnSkeleton()} eventSourceReady = true; stopFallbackPoll(); setConn('ok', 'connected'); + refresh(); + refreshBudget(); }; source.onmessage = function() { eventSourceReady = true; @@ -1354,6 +1356,21 @@ ${columnSkeleton()} } window.addEventListener('pagehide', closeEvents); window.addEventListener('beforeunload', closeEvents); + window.addEventListener('pageshow', function() { + refresh(); + refreshBudget(); + if (!eventSource) connectEvents(); + }); + document.addEventListener('visibilitychange', function() { + if (document.hidden) return; + refresh(); + refreshBudget(); + if (!eventSource) connectEvents(); + }); + window.addEventListener('focus', function() { + refresh(); + refreshBudget(); + }); // ── Roster ────────────────────────────────────────────────────────────── // AGENTS backs the budget panel + resolves a title's "name:" prefix to a @@ -1697,15 +1714,11 @@ ${columnSkeleton()} try { if (status === 'in_progress') { // Claiming needs an agent: use the current assignee, else the one named - // in the title. The architect assigns via MCP/CLI, so most cards arrive - // here already assigned. - var agent = assigned || agentFromTitle(id); - if (!agent) { - toast('No agent yet — let the architect assign this task first', { error: true, ms: 4200 }); - return; - } + // in the title, else mark it as a manual board claim. + var titledAgent = assigned ? '' : agentFromTitle(id); + var agent = assigned || titledAgent || 'manual'; await patchTask(id, { status: 'in_progress', assignedTo: agent }); - toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (assigned ? '' : ' (from title)')); + toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : '')); } else if (status === 'review') { var reviewed = await patchTask(id, { status: 'review' }); toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : '')); diff --git a/src/server/events.ts b/src/server/events.ts index 897f92f..30bb0b6 100644 --- a/src/server/events.ts +++ b/src/server/events.ts @@ -12,6 +12,7 @@ export interface AgentHubEvent { status?: string; role?: string; assignedTo?: string; + claimedBy?: string; reviewer?: string; } diff --git a/src/server/fsWatch.ts b/src/server/fsWatch.ts index 57fc980..2c67517 100644 --- a/src/server/fsWatch.ts +++ b/src/server/fsWatch.ts @@ -60,7 +60,7 @@ function toEvent( case 'task': return { stamp, - event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) }, + event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), claimedBy: str(fm.claimedBy), reviewer: str(fm.reviewer) }, }; case 'handoff': return { diff --git a/src/server/index.ts b/src/server/index.ts index e78b1db..6ce97f7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,6 +11,16 @@ export function buildApp(cwd: string) { return app; } +function isTestRuntime(): boolean { + const lifecycle = process.env.npm_lifecycle_event ?? ''; + return process.env.NODE_ENV === 'test' + || process.env.VITEST === 'true' + || process.env.VITEST_WORKER_ID !== undefined + || process.env.VITEST_POOL_ID !== undefined + || lifecycle === 'test' + || lifecycle.startsWith('test:'); +} + export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> { const app = buildApp(cwd); const port = options.port ?? 3377; @@ -36,14 +46,19 @@ export async function startServer(cwd: string, options: { port?: number; host?: const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`; console.log(`AgentHub: server listening on ${url}`); - const advertiseUrl = resolveAdvertiseUrl(host, actualPort); - broadcaster = startDiscoveryBroadcaster(advertiseUrl); + // Skip LAN advertisers under test: their background timers (mDNS re-advertise + // poll + UDP discovery broadcaster) fire during unrelated tests, flood the + // network with duplicate mDNS publishes ("Service name already in use") and + // leak non-string logs into single-instance assertions. No production change. + if (!isTestRuntime()) { + broadcaster = startDiscoveryBroadcaster(() => resolveAdvertiseUrl(host, actualPort)); - // Advertise a browsable LAN hostname over mDNS (best-effort). - mdns = startMdnsAdvertise({ port: actualPort }); - if (mdns) { - const portSuffix = actualPort === 80 ? '' : `:${actualPort}`; - console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`); + // Advertise a browsable LAN hostname over mDNS (best-effort). + mdns = startMdnsAdvertise({ port: actualPort }); + if (mdns) { + const portSuffix = actualPort === 80 ? '' : `:${actualPort}`; + console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`); + } } return { app, url }; @@ -55,7 +70,7 @@ export async function startServer(cwd: string, options: { port?: number; host?: `Stop it, or start with a different --port.`, ); } else { - console.error(err); + console.error(err instanceof Error ? err.message : String(err)); } process.exit(1); } diff --git a/src/server/mdns.ts b/src/server/mdns.ts index 3a294f8..8d8e662 100644 --- a/src/server/mdns.ts +++ b/src/server/mdns.ts @@ -1,4 +1,5 @@ import { Bonjour, type Service } from 'bonjour-service'; +import { getLanIPv4 } from '../discovery.js'; /** * Advertise the hub over mDNS/Bonjour as `.local`, so anyone on the LAN @@ -15,38 +16,80 @@ export interface MdnsHandle { stop: () => void; } -export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined { +export interface MdnsAdvertiseOptions { + port: number; + name?: string; + pollMs?: number; + getIp?: () => string | undefined; + createBonjour?: () => Bonjour; +} + +export function startMdnsAdvertise(opts: MdnsAdvertiseOptions): MdnsHandle | undefined { const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub'; const hostname = `${base}.local`; - try { - const bonjour = new Bonjour(); - const service: Service = bonjour.publish({ + const pollMs = opts.pollMs ?? 15_000; + const readIp = opts.getIp ?? getLanIPv4; + const createBonjour = opts.createBonjour ?? (() => new Bonjour()); + let currentIp = readIp(); + let bonjour: Bonjour | undefined; + let service: Service | undefined; + let pollTimer: ReturnType | undefined; + + const stopCurrent = () => { + try { + service?.stop?.(); + } catch { + /* ignore */ + } + try { + bonjour?.destroy(); + } catch { + /* ignore */ + } + service = undefined; + bonjour = undefined; + }; + + const publish = () => { + bonjour = createBonjour(); + service = bonjour.publish({ name: 'AgentHub', type: 'http', port: opts.port, host: hostname, - txt: { path: '/board' }, + txt: { path: '/board', address: currentIp ?? '' }, }); // Swallow responder errors — advertisement is optional infrastructure. service.on('error', () => { /* best-effort */ }); + }; + + try { + publish(); + pollTimer = setInterval(() => { + const nextIp = readIp(); + const changed = nextIp !== currentIp; + if (!changed && service) return; + if (changed) { + currentIp = nextIp; + stopCurrent(); + } + try { + publish(); + } catch { + // Keep polling; a later network state may be publishable. + } + }, pollMs); return { hostname, stop: () => { - try { - service.stop?.(); - } catch { - /* ignore */ - } - try { - bonjour.destroy(); - } catch { - /* ignore */ - } + if (pollTimer) clearInterval(pollTimer); + stopCurrent(); }, }; } catch { + stopCurrent(); return undefined; } } diff --git a/src/server/routes.ts b/src/server/routes.ts index b7b3f97..bb64dad 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -44,14 +44,11 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean { } export async function registerRoutes(app: FastifyInstance, cwd: string): Promise { - // Never let a browser cache a hub HTML page — otherwise a stale board/team - // page keeps showing old markup and doesn't reflect live task/agent state. - app.addHook('onSend', async (_request, reply, payload) => { - const ct = reply.getHeader('content-type'); - if (typeof ct === 'string' && ct.includes('text/html')) { - reply.header('Cache-Control', 'no-store, must-revalidate'); - } - return payload; + // Default dynamic responses to no-store so board reloads/fetches never reuse + // stale task JSON or HTML. Static asset routes override this with cacheable + // headers, and the SSE route writes its own raw no-cache header. + app.addHook('onRequest', async (_request, reply) => { + reply.header('Cache-Control', 'no-store, must-revalidate'); }); // Static, self-contained Trello-like board. Polls /tasks, /handoffs and @@ -234,6 +231,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise status: task.status, role: task.role, assignedTo: task.assignedTo, + claimedBy: task.claimedBy, reviewer: task.reviewer, }, task.updatedAt, @@ -312,6 +310,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise status: assigned.status, role: assigned.role, assignedTo: assigned.assignedTo, + claimedBy: assigned.claimedBy, reviewer: assigned.reviewer, }, assigned.updatedAt, @@ -322,8 +321,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise let task: Task; switch (patch.status) { case 'in_progress': - if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)'); - task = claimTask(cwd, id, patch.assignedTo); + { + const current = getTask(cwd, id).task; + const agent = patch.assignedTo?.trim() || current.assignedTo || 'manual'; + task = claimTask(cwd, id, agent); + } break; case 'done': task = doneTask(cwd, id, { @@ -354,6 +356,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise status: task.status, role: task.role, assignedTo: task.assignedTo, + claimedBy: task.claimedBy, reviewer: task.reviewer, }, task.updatedAt, @@ -441,6 +444,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise action: 'created', id: message.id, title: `${message.from} → ${message.to}`, + assignedTo: message.to, }, message.updatedAt, ); diff --git a/tests/discovery.test.ts b/tests/discovery.test.ts index af1300c..b8f0683 100644 --- a/tests/discovery.test.ts +++ b/tests/discovery.test.ts @@ -29,4 +29,17 @@ describe('discovery', () => { broadcaster.stop(); } }); + + it('broadcasts the latest server URL without restarting the broadcaster', async () => { + const port = 53379; + let url = 'http://127.0.0.1:3377'; + const broadcaster = startDiscoveryBroadcaster(() => url, { port, intervalMs: 50 }); + try { + expect(await discoverServer(1000, port)).toBe(url); + url = 'http://127.0.0.1:4477'; + expect(await discoverServer(1000, port)).toBe(url); + } finally { + broadcaster.stop(); + } + }); }); diff --git a/tests/mdns.test.ts b/tests/mdns.test.ts new file mode 100644 index 0000000..4c3a4fe --- /dev/null +++ b/tests/mdns.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { startMdnsAdvertise } from '../src/server/mdns.js'; +import type { Bonjour, Service } from 'bonjour-service'; + +describe('mDNS advertise', () => { + it('republishes after an IP change and stops the old service first', async () => { + let ip = '192.168.1.10'; + const calls: string[] = []; + const published: Array<{ txt?: Record }> = []; + + const createBonjour = () => + ({ + publish(opts: { txt?: Record }) { + calls.push(`publish:${opts.txt?.address ?? ''}`); + published.push(opts); + return { + on: vi.fn(), + stop: vi.fn(() => calls.push('stop')), + } as unknown as Service; + }, + destroy: vi.fn(() => calls.push('destroy')), + }) as unknown as Bonjour; + + const handle = startMdnsAdvertise({ + port: 3377, + pollMs: 25, + getIp: () => ip, + createBonjour, + }); + + expect(handle).toBeDefined(); + expect(published[0]?.txt?.address).toBe('192.168.1.10'); + + ip = '192.168.1.44'; + await new Promise((resolve) => setTimeout(resolve, 70)); + + expect(published).toHaveLength(2); + expect(published[1]?.txt?.address).toBe('192.168.1.44'); + expect(calls).toEqual(['publish:192.168.1.10', 'stop', 'destroy', 'publish:192.168.1.44']); + + handle?.stop(); + }); +}); diff --git a/tests/message-cmd.test.ts b/tests/message-cmd.test.ts new file mode 100644 index 0000000..63ad73a --- /dev/null +++ b/tests/message-cmd.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { init } from '../src/cli/commands/init.js'; +import { messageRead, inboxMarkRead } from '../src/cli/commands/message.js'; +import { createMessage, listInbox } from '../src/core/services/messageService.js'; + +describe('message commands', () => { + let cwd: string; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'ah-msg-cmd-')); + await init(cwd, { yes: true, projectName: 'test' }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('messageRead marks one message as read', () => { + const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done' }); + messageRead(cwd, msg.id); + + expect(listInbox(cwd, 'claude', { unreadOnly: true })).toHaveLength(0); + expect(listInbox(cwd, 'claude')).toMatchObject([{ id: msg.id, status: 'read' }]); + }); + + it('inboxMarkRead bulk-marks unread alias messages for architect', () => { + createMessage(cwd, { from: 'windows-claude', to: 'architect', text: 'ping 1' }); + createMessage(cwd, { from: 'codex', to: 'claude', text: 'ping 2' }); + + expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(2); + inboxMarkRead(cwd, { agent: 'architect', unreadOnly: true }); + + expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(0); + expect(listInbox(cwd, 'claude')).toHaveLength(2); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index ab4b948..d5e2c5a 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -33,6 +33,7 @@ describe('server routes', () => { it('lists tasks via GET /tasks', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); const res = await app.inject({ method: 'GET', url: '/tasks' }); + expect(res.headers['cache-control']).toContain('no-store'); expect(JSON.parse(res.payload)).toHaveLength(1); }); @@ -47,10 +48,18 @@ describe('server routes', () => { expect(res.statusCode).toBe(400); }); - it('returns 400 when claiming without assignedTo', async () => { + it('claims manually when setting in_progress without assignedTo', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); - expect(res.statusCode).toBe(400); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'manual', claimedBy: 'manual' }); + }); + + it('claims with the existing assignee when setting in_progress without assignedTo', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'codex', claimedBy: 'codex' }); }); it('PATCH /tasks/:id → review', async () => { @@ -129,6 +138,10 @@ describe('server routes', () => { } // Board polling stays wired; handoffs/decisions now live on dedicated pages. expect(html).toContain("getJSON('/tasks')"); + expect(html).toContain("cache: 'no-store'"); + expect(html).toContain("window.addEventListener('pageshow'"); + expect(html).toContain("document.addEventListener('visibilitychange'"); + expect(html).toContain("var agent = assigned || titledAgent || 'manual'"); expect(html).toContain('setInterval(refresh'); expect(html).toContain('Token Insights'); expect(html).toContain('data-budget-mode="session"'); diff --git a/tests/single-instance.test.ts b/tests/single-instance.test.ts index 6a94ce1..b8fa10b 100644 --- a/tests/single-instance.test.ts +++ b/tests/single-instance.test.ts @@ -48,10 +48,27 @@ describe('single source of truth', () => { const port = Number(new URL(server.url).port); const logs: string[] = []; const original = console.log; - console.log = (msg: string) => logs.push(msg); - await serverStart(cwd, { host: '127.0.0.1', port }); - console.log = original; + console.log = (msg: unknown) => logs.push(String(msg)); + try { + await serverStart(cwd, { host: '127.0.0.1', port }); + } finally { + console.log = original; + } expect(logs.some((m) => m.includes('already running'))).toBe(true); }); + + it('does not start LAN advertisers during tests', async () => { + const logs: string[] = []; + const original = console.log; + console.log = (msg: unknown) => logs.push(String(msg)); + let isolated: Awaited> | undefined; + try { + isolated = await startServer(cwd, { host: '127.0.0.1', port: 0 }); + } finally { + console.log = original; + } + await isolated?.app.close(); + expect(logs.some((m) => m.includes('reachable in a browser'))).toBe(false); + }); }); }); diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 6c70b94..5392007 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -637,3 +637,43 @@ describe('watch --await-review', () => { await expect(watching).resolves.toBeUndefined(); }, 5000); }); + +// ─── 9. watch --await-message: architect inbox notifier ───────────────────── + +describe('watch --await-message', () => { + let cwd: string; + let server: Awaited>; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'ah-await-message-')); + init(cwd, { projectName: 'await-message', yes: true }); + server = await startServer(cwd, { host: '127.0.0.1', port: 0 }); + }); + + afterEach(async () => { + await server.app.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('returns immediately when an unread alias message already exists', async () => { + await fetch(`${server.url}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'windows-claude', to: 'architect', text: 'need review' }), + }); + + await expect(watchEvents(server.url, { awaitMessage: 'claude' })).resolves.toBeUndefined(); + }, 4000); + + it('exits when a new message arrives for the watched alias', async () => { + const watching = watchEvents(server.url, { awaitMessage: 'architect', newOnly: true }); + await new Promise((r) => setTimeout(r, 200)); + await fetch(`${server.url}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'codex', to: 'claude', text: 'ready' }), + }); + + await expect(watching).resolves.toBeUndefined(); + }, 5000); +}); diff --git a/tests/taskService.test.ts b/tests/taskService.test.ts index 8d02fe4..a136f87 100644 --- a/tests/taskService.test.ts +++ b/tests/taskService.test.ts @@ -37,6 +37,8 @@ describe('taskService', () => { const claimed = claimTask(cwd, task.id, 'codex'); expect(claimed.status).toBe('in_progress'); expect(claimed.assignedTo).toBe('codex'); + expect(claimed.claimedBy).toBe('codex'); + expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' }); const done = doneTask(cwd, task.id); expect(done.status).toBe('done'); }); diff --git a/tests/work.test.ts b/tests/work.test.ts index c1491de..003ba7e 100644 --- a/tests/work.test.ts +++ b/tests/work.test.ts @@ -39,6 +39,7 @@ describe('agenthub work — immediate claim (local)', () => { describe('agenthub work — wait then auto-claim (server SSE)', () => { let cwd: string; let server: Awaited>; + let nextServer: Awaited> | undefined; beforeEach(async () => { cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-')); @@ -47,7 +48,8 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => { }); afterEach(async () => { - await server.app.close(); + await server.app.close().catch(() => undefined); + await nextServer?.app.close().catch(() => undefined); rmSync(cwd, { recursive: true, force: true }); }); @@ -77,4 +79,35 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => { expect(mine?.status).toBe('in_progress'); expect(mine?.assignedTo).toBe('kimi'); }, 6000); + + it('reconnects after the SSE server moves and claims a later task', async () => { + const workDone = workAgent({ + serverUrl: server.url, + projectCwd: cwd, + agent: 'kimi', + role: 'implementer', + timeoutSec: 6, + reconnectBackoffMs: [50, 100], + discoverServer: async () => nextServer?.url, + }); + + await new Promise((r) => setTimeout(r, 200)); + server.app.server.closeAllConnections?.(); + await server.app.close(); + nextServer = await startServer(cwd, { host: '127.0.0.1', port: 0 }); + + await fetch(`${nextServer.url}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'kimi: delegated after reconnect', role: 'implementer' }), + }); + + await workDone; + + const tasks = (await fetch(`${nextServer.url}/tasks`).then((r) => r.json())) as Task[]; + const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi: delegated after reconnect')); + expect(mine).toBeDefined(); + expect(mine?.status).toBe('in_progress'); + expect(mine?.assignedTo).toBe('kimi'); + }, 8000); });