diff --git a/cloud/console-react/src/sections/detail/BrowserPanel.tsx b/cloud/console-react/src/sections/detail/BrowserPanel.tsx
index 21e440c..ff632c7 100644
--- a/cloud/console-react/src/sections/detail/BrowserPanel.tsx
+++ b/cloud/console-react/src/sections/detail/BrowserPanel.tsx
@@ -105,9 +105,23 @@ export default function BrowserPanel() {
holder?: string;
url?: string;
title?: string;
+ waiting?: boolean;
+ task_status?: string;
};
- if (payload.image) {
+ if (payload.waiting) {
+ // Round gap / worker restart: the server keeps
+ // the stream open instead of dropping the watch.
+ // Keep the last frame on screen, just say so.
+ setStatus(
+ payload.task_status === "interrupted"
+ ? "任务已中断,等待续跑…"
+ : payload.task_status === "paused"
+ ? "任务已暂停…"
+ : "等待会话…",
+ );
+ } else if (payload.image) {
setImg("data:image/jpeg;base64," + payload.image);
+ setStatus("直播中");
}
const patch: BrowserState = {};
if (payload.holder) patch.holder = payload.holder;
diff --git a/cloud/console.html b/cloud/console.html
index 8331ec9..5a9e89a 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -4,7 +4,7 @@
Codex 命令台
-
+`)}}p("流已断开")}catch(rt){if(rt.name==="AbortError")return;O(rt instanceof Error?rt.message:String(rt)),p("未开始")}finally{j(!1)}},[r]),L=d.useCallback(()=>{var it;(it=w.current)==null||it.abort(),j(!1),p("已停止")},[]),vt=d.useCallback(async it=>{if(r)try{const Q=await I(K.taskBrowserControl(r.id),"POST",{holder:it});C(Q),O("")}catch(Q){O(Q instanceof Error?Q.message:String(Q))}},[r]),bt=d.useCallback(async it=>{if(!r||(S==null?void 0:S.holder)!=="user"||!X.current)return;const Q=X.current.getBoundingClientRect(),rt=Math.round((it.clientX-Q.left)/Q.width*1280),Ut=Math.round((it.clientY-Q.top)/Q.height*800);try{await I(K.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:rt,y:Ut})}catch(Qt){O(Qt instanceof Error?Qt.message:String(Qt))}},[r,S]),zt=d.useCallback(async it=>{if(!(!r||(S==null?void 0:S.holder)!=="user")){it.preventDefault();try{it.key.length===1?await I(K.taskBrowserInput(r.id),"POST",{type:"type",text:it.key}):await I(K.taskBrowserInput(r.id),"POST",{type:"key",key:it.key})}catch(Q){O(Q instanceof Error?Q.message:String(Q))}}},[r,S]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const J=(S==null?void 0:S.holder)||"—",Rt=S!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",Rt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",J]}),S!=null&&S.url?c.jsx("span",{className:"muted",title:S.url,children:S.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:L,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void at(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void V(),children:"刷新状态"}),J!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Rt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),H?c.jsx("div",{className:"muted",style:{marginTop:8},children:H}):null,J==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:X,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:it=>void bt(it),onKeyDown:it=>void zt(it),style:{width:"100%",maxWidth:1280,cursor:J==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Rt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function xg(){const i=Zt(),[r,h]=d.useState("uncommittedChanges"),[f,S]=d.useState(""),[C,A]=d.useState(""),[p,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:O=>h(O.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:O=>S(O.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:O=>A(O.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:p,onChange:O=>H(O.target.value)}),c.jsxs(wt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:p.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(O=>{let Y=O.target_type||"";return O.branch&&(Y+=":"+O.branch),O.sha&&(Y+=":"+String(O.sha).slice(0,12)),O.instructions&&(Y+=":"+String(O.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(O.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(O.status||""),children:O.status}),O.created_at?" | "+Ya(O.created_at):"",c.jsx("br",{}),O.findings?String(O.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(O.id),children:"写 findings"})]},O.id)})})]})}function jg(){const i=Zt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(cu,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",Ne(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Tg(){const i=Zt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(cu,{children:i.agents.map((f,S)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[f.kind||""]||f.kind||"",":"]}),f.name||""," ",c.jsx("span",{className:"st-"+(f.status||""),children:h[f.status||""]||f.status||"?"})," | 过程调用 "+(f.calls?f.calls.length:0)+" 条",f.updated_ts?" | 最近活动 "+Ya(f.updated_ts/1e3):"",f.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",f.prompt]})]}):null,f.calls&&f.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),f.calls.slice(-50).map((C,A)=>c.jsx("div",{children:vo(C)},A))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},S))})}function _g(){const i=Zt();return c.jsxs(c.Fragment,{children:[c.jsxs(wt,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(xl,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Ya(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function Eg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:C=>h(C.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:C=>S(C.target.value)}),c.jsxs(wt,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),S("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),S("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(xl,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((C,A)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(C.task_id).slice(0,8),"|",C.mode,"|",C.status,"] ",String(C.body||"").slice(0,120)]},A))})]})}function Ng(){const i=Zt(),[r,h]=d.useState(""),f=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:S=>h(S.target.value)}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(xl,{empty:"暂无其他线程",items:i.threadList.map((S,C)=>c.jsxs("span",{className:"row-main",children:[S.name||S.thread_name||S.id||""," [",S.id||"","]",S.archived?" [已归档]":"",S.cwd?" "+S.cwd:""]},C))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Cg(i){return/503|Service Unavailable/.test(i)?"上游模型服务暂时不可用(503):模型一轮都没跑出来,不是任务逻辑失败;等上游恢复或换模型重试":/429|rate limit/i.test(i)?"上游限流(429):模型拒绝了本次调用,可稍后重试":/timeout|timed out/i.test(i)?"上游调用超时:模型响应未在限时内返回":/401|403|Invalid API key|unauthorized/i.test(i)?"上游鉴权失败:网关 key 或权限有问题":/ECONNREFUSED|ENOTFOUND|network|fetch failed/i.test(i)?"连不上上游模型服务:网络或网关地址有问题":"任务失败(原因见原始错误文本)"}function Ag(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const Og=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function Mg(){const i=Zt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Jt,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[S,C]=Wh(r),[A,p]=$h(r),H=Fh(r),O=i.costSummary,Y=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式无敌版(不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:Og.map(([j,w])=>c.jsx("button",{type:"button",className:h===j?"active":"",onClick:()=>f(j),children:w},j))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Jt,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+H,"aria-hidden":"true",children:H}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Fy[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Ya(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:C,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:S})]}),c.jsxs("div",{className:"detail-card",title:p,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:A})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:O===void 0?"加载中…":O===null?"—":(O.llm_requests??0).toLocaleString()}),O===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):O===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",O.turns??0," | fallback ",O.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:O===void 0?"加载中…":O===null?"—":fg(O.usd_micros)}),O===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):O===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(O.total_tokens??0).toLocaleString(),"(实际 ",(O.actual_tokens??0).toLocaleString()," / 估算 ",(O.estimated_tokens??0).toLocaleString(),",缓存 ",(O.cached_input_tokens??0).toLocaleString(),",无价 ",(O.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",O.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const j=Date.now()/1e3,w=r.last_heartbeat||r.updated_at||null,X=w?j-w:null;return c.jsxs(Jt,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",S,X!==null?" · 上次活动 "+Ag(X):""]}),X!==null&&X>180?c.jsx("div",{className:"muted",children:"超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)"}):c.jsx("div",{className:"muted",children:"首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加"})]})})():null,r.error_text?c.jsxs(Jt,{title:"失败原因",children:[c.jsx("div",{children:Cg(r.error_text)}),c.jsx("pre",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.error_text.slice(0,1e3)}),c.jsxs("div",{className:"muted",children:["完整错误文本仍在下方“操作与结果”里;模型 ",r.model||"默认"," · executor ",r.executor||""]})]}):null,c.jsx(Jt,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:Ne(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:Ne(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsx("dd",{children:r.executor||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:Ne(r.project_id)})]}),Y?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:Y})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:Ne(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(j=>j.name).join("、"),")"]})]}):null,r.parent_task_id?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",Ne(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Jt,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Jt,{title:"操作与结果",children:[c.jsx(Sg,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"协调子任务",children:c.jsx(jg,{})}),c.jsx(Jt,{title:"子代理链路",children:c.jsx(Tg,{})}),c.jsx(Jt,{title:"给其他任务发消息",children:c.jsx(Eg,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"评审队列",children:c.jsx(xg,{})}),c.jsx(Jt,{title:"运行时审批队列",children:c.jsx(_g,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"浏览器远控",children:c.jsx(bg,{})})}):c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"线程",children:c.jsx(Ng,{})})})]})}function zg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState([]),[p,H]=d.useState(null),[O,Y]=d.useState(!1),[j,w]=d.useState(""),[X,V]=d.useState(""),at=d.useCallback(async()=>{if(!i.keySaved){A([]);return}try{const J=await I(K.userKeys);A(J.keys||[]),V("")}catch(J){V(J instanceof Error?J.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{at()},[at]);const L=async()=>{w(""),V(""),Y(!1);try{const J=await I(K.userKeys,"POST",{label:f.trim()||"api-key"});H(J),S(""),w("新 key 已生成,只显示一次:复制后请妥善保存。"),await at()}catch(J){V(J instanceof Error?J.message:"生成 key 失败")}},vt=async()=>{if(p)try{await navigator.clipboard.writeText(p.api_key),Y(!0)}catch{V("复制失败:请手动选中上方 key 文本复制")}},bt=()=>{p&&(i.saveKeyInput(p.api_key),H(null),w("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},zt=async J=>{const Rt=(()=>{try{return sl().slice(-4)}catch{return""}})(),it=J.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":J.hint&&J.hint===Rt?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${J.label}」(后 4 位 ${J.hint||"无"})吗?${it}撤销不可恢复。`)){w(""),V("");try{await I(K.userKey(J.id),"DELETE"),w(`已撤销 key「${J.label}」。`),await at()}catch(Q){V(Q instanceof Error?Q.message:"撤销失败")}}};return c.jsxs(Jt,{id:"nav-key",title:"API Key",children:[c.jsxs(wt,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:J=>h(J.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(wt,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:J=>S(J.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void L(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void at(),children:"刷新列表"})]}),p&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",p.label,",后 4 位 ",p.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",readOnly:!0,value:p.api_key,onFocus:J=>J.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void vt(),children:O?"已复制":"复制"}),c.jsx("button",{onClick:bt,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>H(null),children:"关闭(不再显示)"})]})]}),j&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:j})}),X&&c.jsx("div",{children:c.jsx("span",{children:X})}),c.jsxs("div",{className:"api-key-list",children:[C.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),C.map(J=>c.jsxs("div",{children:[c.jsx("span",{children:J.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",J.hint||"无"," · 创建 ",Ya(J.created_at)," · 最近使用 ",J.last_used_at?Ya(J.last_used_at):"未使用过"]})," ",J.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ya(J.revoked_at),")"]}):c.jsx("button",{onClick:()=>void zt(J),children:"撤销"})]},J.id))]})]})]})}function Rg(){const i=Zt();return c.jsxs(Jt,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(wt,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function Dg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState("");return c.jsxs(Jt,{id:"nav-projects",title:"项目",children:[c.jsxs(wt,{children:[c.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:p=>i.selectProject(p.target.value),children:[i.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(p=>c.jsxs("option",{value:p.id,children:[p.name," (",Ne(p.id),")"]},p.id))]}),c.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:p=>h(p.target.value)}),c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:p=>{S(p.target.value),p.target.value&&A(p.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(p=>c.jsx("option",{value:p.clone_url,children:p.repo_full_name},p.clone_url))]}),c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:C,onChange:p=>A(p.target.value)}),c.jsx("button",{onClick:async()=>{await i.createProject(r,C),h(""),A(""),S("")},children:"新建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]})}function Ug(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState("contributor"),C=i.projects.find(A=>A.id===i.projectId);return c.jsxs(Jt,{title:"项目成员",children:[C?c.jsxs("div",{className:"muted",children:[C.name," | ",C.id," | 仓库 ",C.repo_url||"未绑定"," | 我的角色 ",C.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:A=>h(A.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:A=>S(A.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,f),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.members.map(A=>{const p=(A.username||"")+(A.username?"("+String(A.user_id).slice(0,8)+")":String(A.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:p}),c.jsx("select",{"aria-label":"成员角色",value:A.role||"viewer",onChange:H=>void i.changeMemberRole(A.user_id,H.target.value),children:Wy.map(H=>c.jsx("option",{value:H,children:H},H))})]},A.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Hg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState("");return c.jsxs(Jt,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(wt,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.envs.map(p=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[p.name,"(",Ne(p.id),") / 分支 ",p.branch||"默认分支"," / 执行器 ",p.executor||"不限"," / 状态 ",p.status,p.status==="failed"&&p.error_text?" / 失败原因:"+p.error_text:""]}),p.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(p.id),children:"归档"})]},p.id))}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:p=>h(p.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:p=>S(p.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:C,onChange:p=>A(p.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,f,C),h(""),S("")},children:"新建环境"})]}),c.jsx("span",{className:"muted",children:i.envMsg})]})}function wg(){const i=Zt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(wt,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:f=>h(f.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(f=>c.jsx("option",{value:f.clone_url,children:f.repo_full_name},f.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function kg(){const i=Zt();return c.jsxs(Jt,{id:"nav-workers",title:"Workers",children:[c.jsx(wt,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):c.jsx(cu,{children:i.workers.map((r,h)=>{const f=r.running===void 0||r.running===null?"?":r.running,S=r.capacity===void 0||r.capacity===null?"?":r.capacity,C=Jh(r),A=r.alive!==void 0&&r.alive!==null?!!r.alive:C.age!==null&&C.age<=120,p=A?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:A?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[r.worker_id||r.id||"?"," / ",r.executor||""," / 占用 ",f,"/",S,p," / 最后心跳 ",C.text]})},h)})})]})}function Bg(){const i=Zt();return c.jsxs(Jt,{title:"健康与配额",children:[c.jsxs(wt,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function qg(){const i=Zt();return c.jsxs(Jt,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(wt,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(xl,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(xl,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(xl,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const f=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",f?" | "+f:""]},h)})})]})}function Yg(){const i=Zt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"MCP 操作",children:[c.jsxs(wt,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:f=>h(f.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}function Gg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState(""),[p,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:O=>h(O.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:O=>S(O.target.value)}),c.jsxs(wt,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:C,onChange:O=>A(O.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:p,onChange:O=>H(O.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,C,p),h(""),S(""),A(""),H("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(xl,{empty:"(暂无自动化)",items:i.automations.map(O=>c.jsxs("span",{className:"row-main",children:["[",O.status,O.due?"|到期":"","] ",O.name,"|每 ",O.interval_seconds," 秒|下次 ",Ya(O.next_run_at),O.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.cancelAutomation(O.id),children:"取消"})]}):null]},O.id))})]})}function Qg(){var h,f,S,C;const i=Zt(),r=i.triage;return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((h=r.counts)==null?void 0:h.automations_due)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.automations_due||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:[A.name,"|每 ",A.interval_seconds," 秒"]},p))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((f=r.counts)==null?void 0:f.failed_tasks)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.failed_tasks||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:[Ne(A.id),"|",A.status,"|",String(A.prompt||"").slice(0,80)]},p))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((S=r.counts)==null?void 0:S.pending_followups)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.pending_followups||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:["任务 ",Ne(A.task_id),"|",String(A.body||"").slice(0,80)]},p))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((C=r.counts)==null?void 0:C.recent_runs)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.recent_runs||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:[Ne(A.automation_id),"|",A.status,A.task_id?"|任务 "+Ne(A.task_id):"",A.task_status?"|"+A.task_status:""]},p))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function Xg(){return c.jsx(Jt,{id:"nav-automations",title:"自动化",children:c.jsx(Gg,{})})}function Lg(){const i=Zt();return c.jsxs(Jt,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(wt,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Qg,{})]})}const ho=["pending","in_progress","blocked","completed","cancelled"],Rc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},Vg=["low","medium","high"],Lh={low:"低",medium:"中",high:"高"};function Zg(i){return i?new Date(i*1e3).toLocaleString():""}function Kg(){const i=Zt(),[r,h]=d.useState([]),[f,S]=d.useState({}),[C,A]=d.useState(0),[p,H]=d.useState(""),[O,Y]=d.useState(""),[j,w]=d.useState(""),[X,V]=d.useState(""),[at,L]=d.useState(""),[vt,bt]=d.useState(""),[zt,J]=d.useState("medium"),[Rt,it]=d.useState(""),[Q,rt]=d.useState(""),Ut=d.useCallback(async()=>{if(!i.projectId){h([]),S({}),A(0);return}try{let B=K.projectTodos(i.projectId);const ot=new URLSearchParams;j&&ot.set("status",j),X.trim()&&ot.set("q",X.trim());const st=ot.toString();st&&(B+="?"+st);const q=await I(B);h(q.todos||[]),S(q.counts||{}),A(q.total||0),Y("")}catch(B){Y("待办加载失败:"+((B==null?void 0:B.message)||String(B)))}},[i.projectId,j,X]);d.useEffect(()=>{Ut()},[Ut]);const Qt=async()=>{if(!i.projectId){H("先选项目");return}if(!at.trim()){H("标题必填");return}H("");try{const B={title:at.trim(),description:vt,priority:zt};Rt.trim()&&(B.task_id=Rt.trim()),Q&&(B.due_at=Math.floor(new Date(Q).getTime()/1e3)),await I(K.projectTodos(i.projectId),"POST",B),L(""),bt(""),it(""),rt(""),H("已创建"),await Ut()}catch(B){H("创建失败:"+((B==null?void 0:B.message)||String(B)))}},Nt=async(B,ot)=>{if(i.projectId)try{await I(K.projectTodo(i.projectId,B.id),"PATCH",ot),await Ut()}catch(st){Y("待办更新失败:"+((st==null?void 0:st.message)||String(st)))}},kt=async B=>{if(i.projectId&&window.confirm("删除待办“"+B.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await I(K.projectTodo(i.projectId,B.id),"DELETE"),await Ut()}catch(ot){Y("待办删除失败:"+((ot==null?void 0:ot.message)||String(ot)))}};return c.jsxs(Jt,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+C+" 项 | "+ho.map(B=>Rc[B]+" "+(f[B]||0)).join(" | "):"先选项目"}),O?c.jsx("div",{className:"err",children:O}):null,c.jsxs(wt,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:j,onChange:B=>w(B.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),ho.map(B=>c.jsx("option",{value:B,children:Rc[B]},B))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:X,onChange:B=>V(B.target.value)}),c.jsx("button",{onClick:()=>void Ut(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:at,onChange:B=>L(B.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:vt,onChange:B=>bt(B.target.value)}),c.jsxs(wt,{children:[c.jsx("select",{"aria-label":"优先级",value:zt,onChange:B=>J(B.target.value),children:Vg.map(B=>c.jsxs("option",{value:B,children:["优先级:",Lh[B]]},B))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:Rt,onChange:B=>it(B.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:Q,onChange:B=>rt(B.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Qt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:p})]}),c.jsx(xl,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(B=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:B.title})," | ",c.jsx("span",{className:"st-"+B.status,children:Rc[B.status]||B.status})," | 优先级 "+(Lh[B.priority]||B.priority),B.task_id?" | 任务 "+Ne(B.task_id):"",B.due_at?" | 截止 "+Zg(B.due_at):"",B.completed_at?" | 完成于 "+new Date(B.completed_at*1e3).toLocaleString():"",B.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:B.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:B.status,onChange:ot=>void Nt(B,{status:ot.target.value}),children:ho.map(ot=>c.jsx("option",{value:ot,children:Rc[ot]},ot))})," ",c.jsx("button",{onClick:()=>void Nt(B,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void kt(B),children:"删除"})]},B.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const po=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["projects","项目与成员","管理"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],Jg=Object.fromEntries(po.map(([i,r])=>[i,r]));function Vh(){const i=window.location.hash.replace("#view-","");return po.some(([r])=>r===i)?i:"tasks"}function Fg({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),po.filter(([,,f])=>f===h).map(([f,S])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:S},f))]},h))}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(rg,{})]})]})}function Wg(){const i=Zt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function $g(){const i=sg();ug(p=>i.refreshAll(p),1e4),ig(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>Vh()),[f,S]=d.useState(!0),C=p=>{h(p);try{window.history.replaceState(null,"","#view-"+p)}catch{}};d.useEffect(()=>{const p=()=>h(Vh());return window.addEventListener("hashchange",p),()=>window.removeEventListener("hashchange",p)},[]);const A=()=>{C("tasks"),S(!0),setTimeout(()=>{var p;return(p=document.getElementById("sec-dispatch"))==null?void 0:p.scrollIntoView({behavior:"smooth"})},0)};return c.jsx(Kh.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(Fg,{view:r,setView:C}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:Jg[r]}),c.jsx(Wg,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(dg,{onNew:A})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>S(!f),children:f?"收起新建任务":"新建任务"})}),f?c.jsx("section",{id:"sec-dispatch",children:c.jsx(og,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(pg,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(Mg,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(Kg,{})}):r==="projects"?c.jsxs(c.Fragment,{children:[c.jsx(Dg,{}),c.jsx(Ug,{})]}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Hg,{}),c.jsx(wg,{}),c.jsx(kg,{}),c.jsx(Bg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(qg,{}),c.jsx(Yg,{}),c.jsx(Xg,{}),c.jsx(Lg,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Rg,{}),c.jsx(zg,{})]})})]})]})})}Ky.createRoot(document.getElementById("root")).render(c.jsx(qy.StrictMode,{children:c.jsx($g,{})}));
diff --git a/cloud/server.py b/cloud/server.py
index f1b5dbe..694805c 100644
--- a/cloud/server.py
+++ b/cloud/server.py
@@ -5383,9 +5383,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
current holder at least every 5s). The payload also
carries the session url/title, so the console panel can
track the header from the stream alone instead of
- polling the state endpoint while watching. Ends the
- stream when the state file goes stale, so a dead
- session reads as a disconnect, not a frozen picture.
+ polling the state endpoint while watching.
+
+ Stale state does NOT end the stream while the task is
+ still alive: between rounds (and across a worker
+ restart) the MCP is gone for a while and the state file
+ ages past the 30s staleness mark, which used to kill
+ every watch at the first round gap. Now a stale state
+ only switches the stream to waiting mode -- a heartbeat
+ with waiting:true every 5s, task status checked at the
+ same cadence -- and frames resume when the next round's
+ MCP starts polling again. The stream ends only when the
+ task reaches a terminal state (completed, merged,
+ failed), the task row disappears, or the state stays
+ stale for 10 minutes straight (abandoned watch).
"""
import time
from cloud.playwright_remote import (
@@ -5406,23 +5417,62 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
paths = bridge_paths(worktree)
last_frame = None
last_heartbeat = 0.0
+ last_holder = ""
+ stale_since = None
+ last_status_check = 0.0
+ task_status = None
try:
while True:
state, serr = read_state(worktree)
+ now = time.time()
if serr is not None:
- break
+ # No live session right now (round gap,
+ # worker restart, or a blocked drain loop).
+ # Wait it out while the task is still alive
+ # instead of dropping the watch.
+ if stale_since is None:
+ stale_since = now
+ if now - stale_since > 600:
+ break # abandoned watch
+ if now - last_status_check >= 5:
+ last_status_check = now
+ try:
+ row = self._fetch_task(task_id)
+ except Exception: # noqa: BLE001 - keep waiting
+ row = False # transient error, not deletion
+ if row is None:
+ break # task row gone
+ if row is not False:
+ task_status = row["status"]
+ if task_status in (
+ "completed", "merged", "failed"):
+ break
+ if now - last_heartbeat >= 5:
+ last_heartbeat = now
+ payload = json.dumps(
+ {"image": "",
+ "holder": last_holder,
+ "waiting": True,
+ "task_status": task_status or "",
+ "ts": int(now)}, ensure_ascii=False)
+ self.wfile.write(
+ ("data: %s\n\n" % payload).encode("utf-8"))
+ self.wfile.flush()
+ time.sleep(0.02)
+ continue
+ stale_since = None
try:
with open(paths["frame"], "rb") as fh:
frame = fh.read().decode("utf-8")
except (OSError, UnicodeDecodeError):
frame = ""
- now = time.time()
if frame != last_frame or now - last_heartbeat >= 5:
last_frame = frame
last_heartbeat = now
+ last_holder = effective_holder(worktree, state)
payload = json.dumps(
{"image": frame,
- "holder": effective_holder(worktree, state),
+ "holder": last_holder,
"url": state.get("url", ""),
"title": state.get("title", ""),
"ts": int(now)}, ensure_ascii=False)
@@ -5432,6 +5482,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
time.sleep(0.02)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
+ # An SSE stream has no Content-Length, so the client can
+ # only tell "stream ended" from the connection closing.
+ # HTTP/1.1 keep-alive would otherwise leave it hanging
+ # after a terminal break (completed/failed/cap) with no
+ # more bytes ever coming. Force the close on stream end.
+ self.close_connection = True
def _task_chat_history(self, user_id, task_id):
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
diff --git a/cloud/tests/test_server_browser_proxy.py b/cloud/tests/test_server_browser_proxy.py
index 4a31501..a252497 100644
--- a/cloud/tests/test_server_browser_proxy.py
+++ b/cloud/tests/test_server_browser_proxy.py
@@ -174,6 +174,51 @@ class BrowserProxyTestCase(unittest.TestCase):
finally:
conn.close()
+ def test_stream_waits_through_stale_state(self):
+ """A stale state must not drop a live task's watch.
+
+ Regression for the 2026-10-30 demo watch: the old loop
+ broke on the first stale read, so every round gap (and
+ every worker restart) killed the stream. Now a stale
+ state switches to a waiting heartbeat while the task is
+ still alive, and only a terminal task status ends it.
+ """
+ Path(self.wt, ".browser-remote-frame.jpg").write_bytes(b"JPEG1")
+ self._write_state("agent")
+ conn = http.client.HTTPConnection("127.0.0.1", self.port,
+ timeout=30)
+ try:
+ conn.request("GET", "/tasks/task-b1/browser/stream",
+ headers={"X-API-Key": self.owner_key})
+ resp = conn.getresponse()
+ self.assertEqual(resp.status, 200)
+ # Each SSE event is two lines: data + blank separator.
+ line = resp.readline().decode("utf-8")
+ payload = json.loads(line[6:])
+ self.assertEqual(payload["image"], "JPEG1")
+ self.assertEqual(resp.readline().decode("utf-8"), "\n")
+ # Round gap: state goes stale. The stream must stay
+ # open and emit a waiting heartbeat within ~5s.
+ self._write_state("agent", fresh=False)
+ line = resp.readline().decode("utf-8")
+ self.assertTrue(line.startswith("data: "), line)
+ payload = json.loads(line[6:])
+ self.assertTrue(payload.get("waiting"))
+ self.assertEqual(payload.get("task_status"), "running")
+ self.assertEqual(resp.readline().decode("utf-8"), "\n")
+ # Terminal task status ends the stream.
+ db = sqlite3.connect(self.db_path)
+ try:
+ db.execute("UPDATE tasks SET status = 'completed'"
+ " WHERE id = 'task-b1'")
+ db.commit()
+ finally:
+ db.close()
+ line = resp.readline().decode("utf-8")
+ self.assertEqual(line, "")
+ finally:
+ conn.close()
+
if __name__ == "__main__":
unittest.main()