-
-
-
-
-
diff --git a/design-prototypes/agent-office/office.css b/design-prototypes/agent-office/office.css
deleted file mode 100644
index 4580d302b..000000000
--- a/design-prototypes/agent-office/office.css
+++ /dev/null
@@ -1,219 +0,0 @@
-:root {
- color-scheme: light;
- --paper: #f8f7f0; --paper-deep: #eeeee4; --ink: #344b3e; --muted: #5e6d55;
- --line: #d9dfd1; --green: #4c6b48; --green-deep: #354f35; --green-soft: #e3eadb;
- --scene: #e3e9dc; --white: #fffef9;
- font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
- font-size: 13px; color: var(--ink); background: #eeeee5;
-}
-* { box-sizing: border-box; }
-body { margin: 0; }
-button, a, input { -webkit-tap-highlight-color: transparent; }
-button { font: inherit; color: inherit; cursor: pointer; }
-button:disabled { cursor: default; opacity: .45; }
-a { color: inherit; text-decoration: none; }
-button, a { touch-action: manipulation; }
-button:focus-visible, a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; }
-::selection { color: var(--white); background: var(--green); }
-[hidden] { display: none !important; }
-svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: square; stroke-linejoin: miter; flex-shrink: 0; }
-.icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
-.app-shell { max-width: 1600px; margin: 0 auto; padding: 0 38px; }
-.app-header { height: 100px; display: flex; justify-content: space-between; align-items: center; }
-.brand { display: flex; gap: 13px; align-items: center; }
-.brand-mark { background: var(--green); color: #f2efd6; width: 43px; height: 44px; display: grid; place-items: center; clip-path: polygon(4px 0,calc(100% - 4px) 0,calc(100% - 4px) 4px,100% 4px,100% calc(100% - 4px),calc(100% - 4px) calc(100% - 4px),calc(100% - 4px) 100%,4px 100%,4px calc(100% - 4px),0 calc(100% - 4px),0 4px,4px 4px); }
-.brand-mark svg { width: 29px; height: 29px; stroke-width: 1.5; }
-h1 { margin: 0 0 5px; font-size: 21px; font-weight: 700; letter-spacing: .035em; }
-.brand-period { color: #56694d; margin-left: 3px; }
-.brand-subtitle { font-size: 11px; letter-spacing: .19em; color: var(--muted); }
-.header-right { display: flex; align-items: center; gap: 24px; font-size: 12px; }
-.online { display: flex; align-items: center; gap: 7px; }
-.online i, .status-dot { background: #678652; width: 6px; height: 6px; display: inline-block; }
-.day { font-size: 11px; color: var(--muted); letter-spacing: .06em; }
-.day b { color: var(--ink); margin-left: 5px; font-size: 13px; }
-.clock { display: flex; gap: 7px; align-items: center; font-variant-numeric: tabular-nums; }
-.clock svg { width: 15px; height: 15px; }
-.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 7px 13px; border: 1px solid #cfd7c7; background: var(--paper); border-radius: 4px; transition: background 120ms; white-space: nowrap; }
-.button:hover:not(:disabled) { background: var(--green-soft); }
-.primary { background: var(--green); border-color: var(--green); color: var(--white); padding-inline: 15px; }
-.primary:hover:not(:disabled) { background: var(--green-deep); }
-main { background: var(--paper); border: 1px solid #cbd3c3; border-radius: 7px; overflow: hidden; }
-.workspace-header { min-height: 68px; padding: 15px 24px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--line); }
-.workspace-name { display: flex; align-items: center; gap: 12px; min-width: 0; }
-.room-marker { width: 11px; height: 12px; border: 2px solid var(--green); border-top-width: 4px; }
-h2 { font-size: 14px; margin: 0; font-weight: 700; }
-.workspace-slash { color: #56694d; margin: 0 2px; }
-.workspace-subtitle { font-size: 11px; color: var(--muted); }
-.workspace-actions { display: flex; align-items: center; gap: 15px; }
-.demo-label { color: #56694d; font-size: 11px; letter-spacing: .04em; }
-.workspace { display: grid; grid-template-columns: minmax(0,1fr) 270px; }
-.office-section { min-width: 0; background: var(--scene); display: flex; flex-direction: column; border-right: 1px solid var(--line); }
-.scene-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 25px 0; position: relative; z-index: 1; }
-.scene-heading > div { display: flex; align-items: center; gap: 7px; }
-.scene-heading strong { font-size: 12px; font-weight: 400; }
-.live-square { width: 5px; height: 5px; background: #80946f; }
-.scene-caption { font-size: 11px; color: #56694d; letter-spacing: .1em; margin-left: 6px; }
-.scene-weather { display: flex; gap: 7px; align-items: center; font-size: 11px; color: #56694d; }
-.pixel-sun { width: 9px; height: 9px; background: #bdad58; box-shadow: 0 -3px 0 -2px #a99643,0 3px 0 -2px #a99643,3px 0 0 -2px #a99643,-3px 0 0 -2px #a99643; }
-.stage { flex: 1; min-height: 465px; height: min(43vw,610px); position: relative; display: grid; place-items: center; overflow: hidden; }
-#office { position: absolute; inset: 0; min-width: 0; min-height: 0; display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
-.scene-note { position: absolute; bottom: 18px; left: 26px; font-size: 11px; color: #56694d; display: flex; align-items: center; gap: 8px; pointer-events: none; }
-.note-dash { width: 14px; height: 1px; background: #839577; }
-.pause-overlay { position: absolute; top: 18px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 6px; background: var(--paper); border: 1px solid #b9c5af; border-radius: 3px; padding: 8px 12px; color: var(--green); font-size: 11px; }
-.pause-overlay svg { width: 13px; height: 13px; }
-.scene-toolbar { height: 48px; border-top: 1px solid #cfd8c6; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 19px 0 25px; background: rgba(245,247,235,.48); }
-.scene-hint { color: #56694d; font-size: 11px; display: flex; align-items: center; gap: 7px; }
-.scene-hint svg { width: 13px; height: 13px; }
-.view-controls { display: flex; align-items: center; gap: 3px; }
-.icon-button { display: inline-grid; place-items: center; border: 0; background: transparent; width: 31px; height: 31px; border-radius: 3px; }
-.icon-button:hover:not(:disabled) { background: #d5dfca; }
-.icon-button svg { width: 16px; height: 16px; }
-#zoom-label { font-size: 11px; width: 37px; text-align: center; font-variant-numeric: tabular-nums; }
-.control-divider { width: 1px; height: 13px; background: #c3cdb9; margin: 0 5px; }
-.team-panel { padding: 23px 19px 0; display: flex; flex-direction: column; min-width: 0; }
-.panel-heading { display: flex; justify-content: space-between; align-items: center; }
-.count-label { font-size: 11px; color: var(--muted); }
-.team-summary { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--muted); margin-top: 10px; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
-.team-summary > span:first-child { color: var(--green); }
-.team-summary .status-dot { width: 5px; height: 5px; margin-right: 5px; }
-.agent-list { padding: 11px 0 12px; display: grid; gap: 3px; }
-.agent-row { display: grid; grid-template-columns: 38px 1fr auto; width: 100%; gap: 9px; align-items: center; background: transparent; border: 1px solid transparent; padding: 5px 7px; border-radius: 4px; text-align: left; min-height: 49px; }
-.agent-row:hover { background: #eef0e4; }
-.agent-row[aria-pressed="true"] { background: #e8eddc; border-color: #ced9bd; }
-.avatar { width: 35px; height: 35px; background: #e3e7d8; border-radius: 4px; image-rendering: pixelated; }
-.agent-row:nth-child(2) .avatar { background: #dde7e9; }
-.agent-row:nth-child(3) .avatar { background: #f0e3d6; }
-.agent-row:nth-child(4) .avatar { background: #e6e0ed; }
-.agent-row:nth-child(5) .avatar { background: #e6e6dd; }
-.agent-row:nth-child(6) .avatar { background: #e9e5d4; }
-.agent-name { display: block; font-size: 12px; margin-bottom: 3px; }
-.agent-role { color: #56694d; display: block; font-size: 11px; }
-.agent-state { font-size: 11px; color: #56694d; display: flex; gap: 4px; align-items: center; }
-.agent-state::before { content: ''; width: 4px; height: 4px; background: currentColor; }
-.agent-state[data-state="waiting"],.agent-state[data-state="coffee"] { color: #56694d; }
-.agent-detail { padding: 14px 0 17px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
-.detail-heading { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: .06em; color: var(--muted); }
-.detail-label { font-size: 11px; letter-spacing: 0; }
-.detail-name { display: flex; align-items: center; gap: 8px; margin-top: 11px; }
-.detail-name strong { font-size: 14px; }
-#detail-role { font-size: 11px; color: var(--muted); }
-.state-tag { margin-left: auto; font-size: 11px; padding: 3px 5px; background: #e7eadc; color: #56694d; border-radius: 2px; }
-#detail-task { font-size: 11px; line-height: 1.8; color: #56694d; min-height: 36px; margin: 9px 0 11px; }
-.detail-meter { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-bottom: 7px; font-variant-numeric: tabular-nums; }
-.meter { height: 5px; background: #e2e6d9; overflow: hidden; }
-.meter > span { display: block; height: 100%; background: #829b66; width: 0; }
-.activity-section { padding-top: 17px; flex: 1; }
-.activity-heading { display: flex; align-items: center; justify-content: space-between; }
-h3 { font-size: 11px; margin: 0; font-weight: 400; }
-.activity-live { font-size: 11px; color: #56694d; letter-spacing: .08em; }
-.activity-list { padding: 0; margin: 11px 0; list-style: none; display: grid; gap: 10px; }
-.activity-list li { display: grid; grid-template-columns: 31px 1fr; gap: 7px; font-size: 11px; line-height: 1.7; }
-.activity-list time { color: #56694d; font-variant-numeric: tabular-nums; }
-.activity-list span { color: #56694d; }
-.team-footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #56694d; font-size: 11px; min-height: 45px; border-top: 1px solid var(--line); }
-.team-footer svg { width: 14px; height: 14px; }
-.project-bar { display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 22px 26px; border-top: 1px solid var(--line); background: #faf9f3; }
-.project-info { min-width: 0; }
-.project-heading { display: flex; align-items: center; gap: 9px; }
-.project-heading h2 { font-size: 13px; }
-.project-indicator { width: 10px; height: 10px; border: 2px solid #66854f; }
-.project-kind { font-size: 11px; color: #56694d; background: #edf0e4; padding: 3px 6px; border-radius: 3px; white-space: nowrap; }
-.project-meta { display: flex; gap: 8px; margin: 9px 0 0 19px; color: #56694d; font-size: 11px; }
-.meta-dot { color: #56694d; }
-.project-progress { width: clamp(140px,20vw,250px); }
-.project-progress > div:first-child { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #56694d; margin-bottom: 6px; }
-.project-progress strong { color: var(--green); font-size: 18px; line-height: 20px; font-weight: 500; font-variant-numeric: tabular-nums; }
-.project-progress strong span { font-size: 11px; margin-left: 2px; }
-.project-meter { height: 7px; }
-.progress-caption { display: block; font-size: 11px; margin-top: 7px; color: #56694d; }
-.simulation-controls { display: flex; gap: 14px; align-items: center; }
-.pause-button { min-width: 74px; font-size: 11px; }
-.pause-button svg { width: 12px; height: 12px; }
-.speed-control { display: flex; border: 1px solid #d5dcca; border-radius: 4px; padding: 3px; }
-.speed-control button { background: transparent; border: 0; border-radius: 2px; width: 31px; height: 26px; font-size: 11px; }
-.speed-control button[aria-pressed="true"] { background: #e1e8d7; color: #405b34; }
-.speed-control button:hover { background: #ebeee3; }
-.app-footer { display: flex; justify-content: space-between; align-items: center; padding: 18px 2px 22px; font-size: 11px; color: #56694d; }
-.app-footer > span:first-child { display: flex; align-items: center; gap: 7px; }
-.footer-pixel { width: 6px; height: 6px; background: #8ea179; }
-dialog { padding: 0; width: min(660px,calc(100vw - 32px)); max-height: calc(100dvh - 48px); color: var(--ink); background: var(--paper); border: 1px solid #aab99b; border-radius: 7px; }
-dialog::backdrop { background: rgba(42,56,35,.45); }
-.library-heading { padding: 22px 24px; display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--line); }
-.library-heading h2 { font-size: 18px; }
-.library-heading p { color: var(--muted); font-size: 11px; margin: 8px 0 0; }
-.library-content { padding: 20px 24px 24px; }
-.library-content h3 { font-weight: 700; margin-bottom: 12px; }
-.asset-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); margin-bottom: 25px; }
-.asset-tile { display: flex; flex-direction: column; align-items: center; padding: 4px 4px 10px; background: #f0f1e7; font-size: 11px; color: #56694d; }
-.asset-tile canvas { width: 100%; max-width: 110px; height: 92px; image-rendering: pixelated; object-fit: contain; }
-.portrait-grid { display: grid; grid-template-columns: repeat(6,minmax(0,1fr)); gap: 12px; text-align: center; font-size: 11px; }
-.portrait-grid canvas { display: block; width: 100%; height: auto; background: #e7eadc; image-rendering: pixelated; margin-bottom: 7px; }
-.library-footnote { font-size: 11px; color: var(--muted); line-height: 1.9; margin: 22px 0 12px; }
-#asset-stats { font-size: 11px; color: #56694d; }
-.toast { position: fixed; bottom: 25px; left: 50%; transform: translateX(-50%); background: #344c37; color: #56694d; padding: 12px 18px; border-radius: 4px; font-size: 12px; max-width: calc(100vw - 32px); z-index: 10; }
-.noscript { padding: 20px; text-align: center; }
-* { scrollbar-width: thin; scrollbar-color: #bbc7ad var(--paper); }
-@media (min-width: 1550px) { .stage { height: 625px; } }
-@media (max-width: 1150px) {
- .app-shell { padding: 0 22px; } .header-right { gap: 15px; } .workspace { grid-template-columns: minmax(0,1fr) 242px; }
- .team-panel { padding-inline: 14px; } .scene-caption,.scene-weather { display: none; } .stage { min-height: 460px; }
- .project-bar { padding-inline: 20px; gap: 16px; } .simulation-controls { gap: 8px; } .project-kind { display: none; }
-}
-@media (max-width: 860px) {
- .app-shell { padding: 0 16px; } .app-header { height: 84px; } h1 { font-size: 18px; } .brand-subtitle { font-size: 11px; }
- .online,.day,.workspace-subtitle,.workspace-slash { display: none; } .workspace-header { padding: 13px 18px; min-height: 62px; }
- .workspace { grid-template-columns: minmax(0,1fr); } .office-section { border-right: 0; } .stage { height: 65vw; min-height: 330px; }
- .team-panel { padding: 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 0 24px; border-top: 1px solid var(--line); }
- .panel-heading,.team-summary { grid-column: 1; } .team-summary { padding-bottom: 10px; } .agent-list { grid-column: 1; grid-row: 3 / 5; }
- .agent-detail { grid-column: 2; grid-row: 1 / 4; border-top: 0; padding-top: 0; align-self: start; }
- .activity-section { grid-column: 2; grid-row: 4; padding-top: 4px; } .team-footer { grid-column: 1 / 3; }
- .project-bar { flex-wrap: wrap; gap: 20px; } .project-info { flex: 1 1 50%; } .project-progress { flex: 1 1 30%; }
- .simulation-controls { margin-left: auto; } .scene-weather { display: flex; } .app-footer > span:last-child { font-size: 11px; }
-}
-@media (max-width: 520px) {
- .app-shell { padding: 0 10px; } .brand-mark { width: 35px; height: 36px; } .brand-mark svg { width: 24px; height: 24px; }
- .brand { gap: 8px; } h1 { font-size: 16px; letter-spacing: 0; } .brand-subtitle { font-size: 11px; letter-spacing: .11em; }
- .header-right { gap: 10px; } .clock { display: none; } .library-button { padding: 7px; min-height: 33px; font-size: 11px; }
- .workspace-header { padding: 12px; } .workspace-actions { gap: 8px; } .demo-label { font-size: 11px; } .primary { font-size: 11px; padding-inline: 9px; min-height: 33px; }
- .scene-heading { padding: 16px 14px 0; } .scene-heading strong { font-size: 11px; } .scene-weather { font-size: 11px; }
- .stage { min-height: 280px; height: 76vw; } .scene-note { left: 15px; bottom: 12px; font-size: 11px; }
- .scene-toolbar { padding: 0 8px 0 12px; } .scene-hint { font-size: 11px; gap: 4px; } .view-controls { gap: 0; }
- .icon-button { width: 29px; height: 32px; } .control-divider { margin: 0 2px; } #zoom-label { width: 30px; font-size: 11px; }
- .team-panel { padding: 18px 12px 0; gap: 0 13px; grid-template-columns: minmax(0,1.1fr) minmax(0,1fr); }
- .team-summary { gap: 6px; font-size: 11px; } .agent-row { grid-template-columns: 29px 1fr auto; gap: 5px; padding: 5px 3px; }
- .avatar { width: 28px; height: 32px; } .agent-name { font-size: 11px; } .agent-role { font-size: 11px; } .agent-state { font-size: 11px; }
- .detail-name { flex-wrap: wrap; gap: 5px; } #detail-role { font-size: 11px; } .state-tag { margin-left: 0; } #detail-task { font-size: 11px; }
- .detail-heading { font-size: 11px; } .detail-label { font-size: 11px; } .activity-list li { font-size: 11px; grid-template-columns: 27px 1fr; gap: 3px; }
- .project-bar { padding: 18px 14px; gap: 16px; } .project-info { flex-basis: 100%; } .project-heading h2 { font-size: 12px; }
- .project-progress { flex-basis: 40%; } .progress-caption { font-size: 11px; } .simulation-controls { gap: 6px; }
- .pause-button { min-width: 58px; padding: 6px 8px; } .speed-control button { width: 26px; }
- .app-footer { padding-block: 15px; font-size: 11px; } .app-footer > span:last-child { max-width: 155px; text-align: right; line-height: 1.6; }
- .asset-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } .portrait-grid { gap: 6px; } .library-content { padding: 16px; }
-}
-@media (prefers-reduced-motion: reduce) { *,*::before,*::after { transition: none !important; } }
-
-@media (max-width: 520px) {
- .brand-subtitle { font-size: 10px; letter-spacing: 0; }
- .demo-label { max-width: 65px; text-align: right; line-height: 1.4; }
- .scene-weather { max-width: 110px; text-align: right; line-height: 1.5; }
- .scene-hint { max-width: 170px; line-height: 1.5; }
- .team-panel { display: block; padding: 18px 14px 0; }
- .agent-list { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 5px 9px; padding: 12px 0 16px; }
- .agent-row { min-height: 58px; grid-template-columns: 30px 1fr; gap: 2px 6px; padding: 6px; }
- .agent-row .avatar { grid-row: 1 / 3; }
- .agent-state { grid-column: 2; margin-top: 3px; }
- .agent-role { line-height: 1.5; }
- .agent-name { font-size: 12px; }
- .agent-detail { border-top: 1px solid var(--line); padding-top: 15px; }
- .detail-name { gap: 9px; } .state-tag { margin-left: auto; }
- #detail-task { min-height: 0; }
- .activity-section { padding-top: 16px; }
- .activity-list li { grid-template-columns: 37px 1fr; gap: 8px; }
- .project-progress { flex: 1 1 100%; }
- .simulation-controls { width: 100%; justify-content: flex-end; }
- .app-footer { align-items: flex-start; gap: 12px; line-height: 1.6; }
- .library-heading { padding: 18px 16px; }
- .portrait-grid { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 15px; }
-}
-
-@media (max-width: 370px) { .library-button span { display: none; } .brand-subtitle { font-size: 10px; } }
diff --git a/design-prototypes/agent-office/renderer.js b/design-prototypes/agent-office/renderer.js
deleted file mode 100644
index 2d9f98e47..000000000
--- a/design-prototypes/agent-office/renderer.js
+++ /dev/null
@@ -1,147 +0,0 @@
-/* Room caching and depth composition use the same atlas as all UI previews. */
-(function (O) {
- 'use strict';
- const {C,canvas,rect,line,poly,iso,plane,box,panel}=O.Art;
- const origin={x:340,y:155}, size={x:14,y:11};
- const project=(x,y,z=0)=>{ const p=iso(x,y,z); return {x:origin.x+p[0],y:origin.y+p[1]}; };
- function windowPanel(c,start,width,left=false) {
- c.save(); if(left) c.scale(-1,1);
- panel(c,start,-.015,36,width,47,'#aaa995','#808975');
- panel(c,start+.08,.0,39,width-.16,43,'#f8f4e4');
- panel(c,start+.16,.025,42,width-.32,37,'#a4ccda','#8bafba');
- panel(c,start+.18,.03,44,width-.36,13,'#b9dce3');
- for(let i=0;i<5;i++) {
- const x=start+.19+i*(width-.4)/5,h=7+(i*7)%8;
- panel(c,x,.035,43,(width-.4)/5+.01,h,i%2?'#91b9ad':'#a2c5b7');
- }
- for(let i=1;i<3;i++) panel(c,start+width*i/3-.025,.045,42,.075,37,'#f6f3e2','#b1c3bc');
- panel(c,start+.16,.05,61,width-.32,2,'#d3e8e5');
- box(c,start-.04,.04,width+.08,.18,3,35,['#f6f3e7','#cac9b5','#e0dfcc'],'#a5ad97');
- c.restore();
- }
- function tinyTree(c,x,y,scale=1) {
- c.save(); c.translate(x,y); c.scale(scale,scale);
- rect(c,-2,0,4,14,'#91856a');
- for(const [a,b,w,h,color] of [[-10,-13,20,17,'#9bab85'],[-14,-9,27,11,'#94a77b'],[-8,-17,17,11,'#b1bf96'],[-4,-20,10,8,'#b9c59e']]) rect(c,a,b,w,h,color);
- rect(c,-10,-4,6,4,'#82986a'); c.restore();
- }
- function buildRoom(atlas) {
- const result=canvas(768,512),c=result.getContext('2d'); c.imageSmoothingEnabled=false;
- // Garden fragments and the offset foundation ground the cutaway room.
- tinyTree(c,77,303,1.2); tinyTree(c,703,333,.9); tinyTree(c,664,378,.65);
- for(const [x,y] of [[87,332],[663,408],[492,479],[160,393],[58,285]]) {
- rect(c,x,y,2,3,'#b2bfa3'); rect(c,x+4,y-1,1,3,'#a8b798');
- }
- c.save(); c.translate(origin.x,origin.y);
- poly(c,[iso(-.15,.0,-11),iso(14.5,.1,-11),iso(14.5,11.3,-11),iso(-.15,11.3,-11)],'rgba(91,107,73,.16)');
- box(c,0,0,size.x,size.y,9,-9,['#c7d1b9','#a3ac94','#b2b9a2'],'#858f76');
- for(let y=0;y=0;i--) { const h=this.hits[i]; if(p.x>=h.x-17*this.zoom&&p.x<=h.x+18*this.zoom&&p.y>=h.y-52*this.zoom&&p.y<=h.y+7*this.zoom) return h.index; } return -1; }
- bubble(c,x,y,text,color='#526a43') {
- c.font='10px "Microsoft YaHei", sans-serif'; const width=Math.ceil(c.measureText(text).width)+16;
- rect(c,x-width/2,y-19,width,17,'#839375'); rect(c,x-width/2-1,y-17,width+2,13,'#839375');
- rect(c,x-width/2+1,y-18,width-2,15,'#fffdf1'); rect(c,x-width/2,y-16,width,11,'#fffdf1');
- poly(c,[[x-3,y-2],[x+3,y-2],[x,y+2]],'#839375'); poly(c,[[x-2,y-3],[x+2,y-3],[x,y]],'#fffdf1');
- c.fillStyle=color;c.textAlign='center';c.fillText(text,Math.round(x),Math.round(y-7));
- }
- render() {
- const c=this.ctx,sim=this.sim,atlas=this.atlas;
- c.imageSmoothingEnabled=false; c.clearRect(0,0,768,512); c.save();
- c.translate(384,280);c.scale(this.zoom,this.zoom);c.translate(-384,-280);
- c.drawImage(this.background,0,0);
- const selected=sim.agents[sim.selected],sp=project(selected.x,selected.y);
- c.save();c.translate(sp.x,sp.y+1);plane(c,-.47,-.43,.94,.86,0,'rgba(244,244,197,.4)','#829c61'); c.restore();
- const items=this.furniture.map(item=>({...item,depth:item.depth===undefined?item.x+item.y:item.depth}));
- for(const a of sim.agents) items.push({type:'person',x:a.x,y:a.y,agent:a,depth:a.x+a.y+.05});
- items.sort((a,b)=>a.depth-b.depth);
- this.hits=[];
- for(const item of items) {
- const p=project(item.x,item.y);
- if(item.type==='person') {
- const a=item.agent,frame=a.action==='walk'?Math.floor(sim.time*7+a.index)%4:a.action==='idle'?(Math.floor(sim.time+a.index*1.7)%6===0?1:0):Math.floor(sim.time*4+a.index)%2;
- atlas.draw(c,atlas.person(a.index,a.action,frame),p.x,p.y);
- this.hits.push({...this.screen(a.x,a.y),index:a.index});
- } else atlas.draw(c,item.sprite,p.x,p.y);
- }
- // Name plates are a display layer; all hit tests target real actor positions.
- for(const a of sim.agents) {
- const p=project(a.x,a.y),active=a.index===sim.selected||a.index===this.hover;
- c.font='8px "Microsoft YaHei", sans-serif'; c.textAlign='center';
- const name=a.name,width=24;
- rect(c,p.x-width/2,p.y+5,width,12,active?'#506947':'rgba(249,249,232,.90)');
- c.fillStyle=active?'#fffce8':'#5c7050'; c.fillText(name,p.x,p.y+14);
- }
- for(const a of sim.agents) {
- const selected=a.index===sim.selected;
- const show=selected||(a.action==='work'&&Math.floor(sim.time/4+a.index)%3===0)||(a.action==='coffee');
- if(!show)continue;
- const p=project(a.x,a.y),status=sim.status(a.index);
- const text=a.action==='coffee'?'补充一点灵感':status.label+(a.action==='work'?'...':'');
- this.bubble(c,p.x+4,p.y-53,text,selected?'#46653c':'#677a58');
- }
- // Shared coffee corner: tiny steam frames animate without rebuilding furniture.
- const coffee=project(10.55,.95,50),phase=Math.floor(sim.time*3)%3;
- line(c,coffee.x,coffee.y-3-phase,coffee.x+1,coffee.y-7-phase,'#ecebd6');
- if(sim.finished) { const p=project(7,4,82); this.bubble(c,p.x,p.y,'好想法,完成了!'); }
- c.restore();
- }
- }
- O.Renderer=Renderer;
-})(globalThis.PixelOffice = globalThis.PixelOffice || {});
diff --git a/design-prototypes/agent-office/simulation.js b/design-prototypes/agent-office/simulation.js
deleted file mode 100644
index e69fa72e8..000000000
--- a/design-prototypes/agent-office/simulation.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/* Pure, deterministic simulation: explicit time in, structured team state out. */
-(function (O) {
- 'use strict';
- const desks = [[4,3.5],[7.2,3.5],[10.4,3.5],[4,6.7],[7.2,6.7],[10.4,6.7]];
- const definitions = [
- { title:'拆解需求', doing:'规划中', text:'把好想法拆成可执行的小任务,安排大家的分工。', duration:18, deps:[] },
- { title:'开发功能', doing:'开发中', text:'搭建工作台,编写交互逻辑,让每个小功能运转起来。', duration:48, deps:[0] },
- { title:'设计界面', doing:'设计中', text:'打磨布局、颜色与像素细节,给好想法一个好看的样子。', duration:35, deps:[0] },
- { title:'资料研究', doing:'研究中', text:'收集参考,梳理技术方案,把发现分享给伙伴。', duration:30, deps:[] },
- { title:'代码审查', doing:'审查中', text:'和开发伙伴一起检查实现,找出可以做得更好的地方。', duration:23, deps:[1,2,3] },
- { title:'质量验证', doing:'测试中', text:'验证关键交互和边界情况,确保作品可以放心交付。', duration:22, deps:[4] },
- ];
- const titles=['做一个有趣的 AI 工作台','为小团队设计一个灵感收集器','做一个会照顾番茄钟的助手','把今天的好想法变成小网站'];
- const distance=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
- class Simulation {
- constructor() {
- this.time=0; this.speed=1; this.paused=false; this.projectNumber=0; this.selected=1; this.events=[];
- this.agents=O.profiles.map((p,i)=>({ ...p,index:i,desk:{x:desks[i][0],y:desks[i][1]},home:{x:desks[i][0],y:desks[i][1]+.96},x:desks[i][0],y:desks[i][1]+.96,action:'idle',route:[],destination:'home',nextBreak:8+i*3,coffeeUntil:0 }));
- this.startProject(true);
- }
- get active() { return this.tasks.some(t=>t.elapsedn+t.elapsed/t.duration,0)/this.tasks.length; }
- get doneCount() { return this.tasks.filter(t=>t.elapsed>=t.duration).length; }
- get clock() { const m=9*60+41+Math.floor(this.time/3); return String(Math.floor(m/60)%24).padStart(2,'0')+':'+String(m%60).padStart(2,'0'); }
- log(text) { this.events.unshift({time:this.clock,text}); if(this.events.length>5) this.events.length=5; }
- startProject(initial=false) {
- if(!initial&&this.active) return false;
- this.title=titles[this.projectNumber%titles.length]; this.projectNumber++;
- this.tasks=definitions.map(d=>({...d,deps:[...d.deps],elapsed:0}));
- if(initial) { this.tasks[0].elapsed=18; this.tasks[1].elapsed=8; this.tasks[2].elapsed=9; this.tasks[3].elapsed=19; }
- this.finished=false;
- this.log(initial?'阿策已拆解需求,伙伴们开始协作':'新任务已派发,阿策开始拆解需求');
- if(initial) { this.log('小寻整理了第一批参考资料'); this.log('阿绘和小码开始并行设计、开发'); }
- this.refreshActions(); return true;
- }
- ready(i) { const t=this.tasks[i]; return t.elapsedthis.tasks[d].elapsed>=this.tasks[d].duration); }
- atHome(a) { return distance(a,a.home)<.02; }
- coffeeRoute(a) {
- const lane=a.desk.y+1.9;
- return [{...a.home},{x:a.home.x,y:lane},{x:12.15,y:lane},{x:12.15,y:1.9},{x:10.8,y:1.9}];
- }
- goCoffee(a) { a.destination='coffee'; a.route=this.coffeeRoute(a).slice(1); a.action='walk'; }
- goHome(a) { a.destination='home'; a.route=this.coffeeRoute(a).slice(0,-1).reverse(); a.action='walk'; }
- move(a,dt) {
- let remaining=dt*1.5;
- while(a.route.length&&remaining>0) {
- const target=a.route[0],d=distance(a,target);
- if(d<=remaining) { a.x=target.x; a.y=target.y; remaining-=d; a.route.shift(); }
- else { a.x+=(target.x-a.x)/d*remaining; a.y+=(target.y-a.y)/d*remaining; remaining=0; }
- }
- if(!a.route.length) {
- if(a.destination==='coffee') { a.action='coffee'; a.coffeeUntil=this.time+5; }
- else { a.action='idle'; a.nextBreak=this.time+14+a.index*2; }
- }
- }
- refreshActions() {
- for(const a of this.agents) if(!a.route.length&&this.atHome(a)) a.action=this.ready(a.index)?'work':'idle';
- }
- tick(dt) {
- if(this.paused||!Number.isFinite(dt)||dt<=0) return;
- // Substeps preserve dependency ordering even for a long caller-supplied step.
- let remaining=dt*this.speed;
- while(remaining>0) { const step=Math.min(.1,remaining); this.step(step); remaining-=step; }
- }
- step(dt) {
- this.time+=dt;
- for(const a of this.agents) {
- const t=this.tasks[a.index];
- if(a.route.length) { this.move(a,dt); continue; }
- if(a.action==='coffee') {
- if(this.ready(a.index)||this.time>=a.coffeeUntil) this.goHome(a);
- continue;
- }
- if(this.atHome(a)&&this.ready(a.index)) {
- a.action='work'; const before=t.elapsed; t.elapsed=Math.min(t.duration,t.elapsed+dt);
- if(before=t.duration) { this.log(a.name+'完成了'+t.title); a.action='idle'; a.nextBreak=this.time+2+a.index; }
- } else {
- a.action='idle';
- const coffeeBusy=this.agents.some(other=>other!==a&&other.destination==='coffee'&&(other.route.length||other.action==='coffee'));
- if(this.time>=a.nextBreak&&!coffeeBusy) this.goCoffee(a);
- }
- }
- if(!this.active&&!this.finished) { this.finished=true; this.log('作品完成!大家一起把想法变成了现实'); }
- }
- status(index) {
- const a=this.agents[index],t=this.tasks[index];
- if(a.action==='walk') return {label:a.destination==='home'?'回工位':'走动中',state:'walking'};
- if(a.action==='coffee') return {label:'咖啡时间',state:'coffee'};
- if(a.action==='work') return {label:t.doing,state:'working'};
- return {label:t.elapsed>=t.duration?'已完成':'准备中',state:t.elapsed>=t.duration?'done':'waiting'};
- }
- }
- O.Simulation=Simulation; O.desks=desks;
-})(globalThis.PixelOffice = globalThis.PixelOffice || {});
diff --git a/design-prototypes/agent-office/simulation.test.cjs b/design-prototypes/agent-office/simulation.test.cjs
deleted file mode 100644
index 83746032f..000000000
--- a/design-prototypes/agent-office/simulation.test.cjs
+++ /dev/null
@@ -1,78 +0,0 @@
-// Run from any directory: node --test simulation.test.cjs
-const test = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('node:fs');
-const path = require('node:path');
-const vm = require('node:vm');
-const context = vm.createContext({});
-for (const name of ['assets.js', 'simulation.js']) {
- vm.runInContext(fs.readFileSync(path.join(__dirname, name), 'utf8'), context, { filename: name });
-}
-const { Simulation } = context.PixelOffice;
-const snapshot = sim => JSON.stringify({ time: sim.time, tasks: sim.tasks, agents: sim.agents, events: sim.events });
-
-test('paused time freezes tasks, actor motion and the event log', () => {
- const sim = new Simulation(); sim.tick(10); sim.paused = true;
- const before = snapshot(sim); sim.tick(120); assert.equal(snapshot(sim), before);
- sim.paused = false; sim.tick(1); assert.ok(sim.time > 10);
-});
-
-test('speed scales explicit elapsed time exactly once', () => {
- const normal = new Simulation(), fast = new Simulation(); fast.speed = 4;
- normal.tick(4); fast.tick(1);
- assert.ok(Math.abs(normal.time - fast.time) < 1e-8);
- for (let i = 0; i < 6; i++) assert.ok(Math.abs(normal.tasks[i].elapsed - fast.tasks[i].elapsed) < 1e-8);
-});
-
-test('invalid time input cannot corrupt simulation state', () => {
- const sim = new Simulation(), before = snapshot(sim);
- for (const dt of [NaN, Infinity, -1, 0]) sim.tick(dt);
- assert.equal(snapshot(sim), before);
-});
-
-test('dependent work starts only after handoff, and only at the desk', () => {
- const sim = new Simulation();
- for (let frame = 0; frame < 1600; frame++) {
- const before = sim.tasks.map(t => t.elapsed); sim.tick(.1);
- for (const actor of sim.agents) {
- const t = sim.tasks[actor.index];
- if (t.elapsed > before[actor.index]) {
- assert.ok(t.deps.every(i => sim.tasks[i].elapsed === sim.tasks[i].duration));
- assert.ok(Math.hypot(actor.x - actor.home.x, actor.y - actor.home.y) < .02);
- }
- }
- }
- assert.equal(sim.doneCount, 6); assert.equal(sim.progress, 1);
-});
-
-test('coffee breaks stay inside the office, reserve one spot, and return', () => {
- const sim = new Simulation(); let sawWalk = false, sawCoffee = false, sawReturn = false;
- for (let frame = 0; frame < 2200; frame++) {
- sim.tick(.1);
- const reserved = sim.agents.filter(a => a.destination === 'coffee' && (a.route.length || a.action === 'coffee'));
- assert.ok(reserved.length <= 1);
- for (const a of sim.agents) {
- assert.ok(a.x >= 0 && a.x <= 14 && a.y >= 0 && a.y <= 11);
- sawWalk ||= a.action === 'walk'; sawCoffee ||= a.action === 'coffee';
- sawReturn ||= a.destination === 'home' && a.route.length > 0;
- }
- }
- assert.ok(sawWalk && sawCoffee && sawReturn); assert.equal(sim.doneCount, 6);
-});
-
-test('duplicate dispatch is rejected, a finished project can start fresh', () => {
- const sim = new Simulation(), before = snapshot(sim);
- assert.equal(sim.startProject(), false); assert.equal(snapshot(sim), before);
- sim.tick(240); assert.equal(sim.finished, true); assert.equal(sim.active, false);
- assert.equal(sim.startProject(), true); assert.equal(sim.projectNumber, 2);
- assert.equal(sim.progress, 0); assert.equal(sim.finished, false);
- assert.equal(sim.startProject(), false); sim.tick(240);
- assert.equal(sim.doneCount, 6); assert.equal(sim.finished, true);
-});
-
-test('a large explicit time step still resolves the entire dependency chain', () => {
- const sim = new Simulation(); sim.tick(300);
- assert.equal(sim.doneCount, 6);
- assert.ok(sim.tasks.every(t => t.elapsed === t.duration));
- assert.equal(sim.events.filter(e => e.text.includes('作品完成')).length, 1);
-});
diff --git a/design-prototypes/agent-office/surface.md b/design-prototypes/agent-office/surface.md
deleted file mode 100644
index 410cae655..000000000
--- a/design-prototypes/agent-office/surface.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# 办公室演示页
-
-模式:Experience。
-
-首先看到可活动的等距像素办公室,其次读取成员与当前项目。重点是角色的工作动作、任务状态变化和统一的像素材质。全页只保留有实际行为的控件。演示数据标识位于顶部。
diff --git a/docs/channels.md b/docs/channels.md
index abc7a1114..ddd885836 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -1,12 +1,269 @@
-# WhatsApp Channels
+# Messaging Channels
+
+ACECode can talk to you through instant-messaging apps. **QQ** (official bot, API
+v2), **WeChat** (Tencent iLink bot), **Feishu / Lark**, **DingTalk**, **Telegram**
+(Bot API), **Discord** and **LINE** (Messaging API) are built into the daemon and
+configured in **Settings > Integrations > Messaging channels** in Desktop or the
+local Web UI. **WhatsApp** is configured in a terminal; see [WhatsApp](#whatsapp).
+
+## Built-in Channels
+
+All seven channels are native C++: transports in `src/adapters/im//`,
+routing, access and storage in `src/host/channels/core/`. The platform list,
+credential fields and identity rules live in one table
+(`src/host/channels/core/platforms.cpp`). Every platform except LINE connects
+outward from this computer (WebSocket or long polling), so no public address,
+webhook server or Node/Python runtime is needed. LINE only delivers to a public
+HTTPS address; see below. All HTTP and WebSocket traffic follows ACECode's proxy
+settings.
+
+### Setup
+
+The page shows the platforms as cards in two columns. Each card has one button:
+**Connect** while the platform is off, **Disconnect** while it is on. Both take
+effect immediately; there is no separate Save. A card shows only a one-line
+status (connecting, connected with the bot's name, reconnecting, failed, or held by
+another ACECode process) and any pending approval requests. Details such as
+failure reasons, group privacy mode, held QQ outputs, bindings and send counts are
+written to the daemon log (`/logs/daemon-.log`, lines tagged
+`[channels/]`) instead of the page. A failed card
+offers **Fix**, which reopens the connect step. A configured card offers
+**Manage** to change the bot or revoke the owner and approved contacts.
+
+**Connect** on a platform that has no credentials or no owner yet opens a
+three-step dialog: set up the bot, connect, and set the owner. When credentials
+and owner both exist, **Connect** connects right away without the dialog.
+
+**QQ.** The first step shows a QR code as soon as it opens; scan it with mobile
+QQ. ACECode requests a bind task from Tencent's bot binding service (the flow
+used by the official connector, with source `acecode`), shows the bind page as a
+QR code, refreshes expired codes automatically and gives up after ten minutes. On
+success the AppID and the AppSecret, decrypted locally with a one-time AES-256-GCM
+key, are saved and QQ is switched on. The person who scanned becomes the owner
+when the service reports them; otherwise the first person to message the bot
+privately within ten minutes becomes the owner, and after that window approving
+the first private request does. Cancelling or failing leaves the existing
+configuration unchanged.
+
+**Enter AppID / AppSecret manually** in the same step accepts the credentials of a
+bot from QQ Open Platform. They are verified by exchanging an access token before
+saving. Bots without personal verification can only be used by their
+administrator and can only join groups the administrator owns; the dialog says
+so when it finishes.
+
+**Telegram.** The first step explains how to create a bot with @BotFather; paste
+its token and it is verified with `getMe` before saving. The owner step creates a
+one-time `https://t.me/?start=` link valid for ten minutes and shows it
+as a QR code. Whoever opens it and taps **Start** becomes the owner. Scan the QR
+code with the phone's camera app; Telegram's own scanner only links devices and
+cannot read it. On a computer where Telegram is signed in, click the link or use
+**Copy link** instead. With privacy mode on (the BotFather default) groups deliver
+only @mentions, replies to the bot and commands, which matches the mention-only
+group behavior; with it off the bot sees every group message but still handles
+only @mentions. If the bot has a webhook, polling is blocked; the connect step
+offers **Remove webhook and connect**, after explaining that the service
+currently receiving the webhook stops getting messages.
+
+**WeChat.** The first step shows a QR code from Tencent's iLink bot service;
+scan it with WeChat and confirm on the phone. The WeChat account that scanned
+becomes the owner automatically and is the only one who can talk to the bot;
+WeChat bots have private chats only. The bot token is saved locally. When the
+service reports the login has expired, the card shows a failure and the dialog
+asks you to scan again. Media goes through Tencent's CDN, encrypted with
+AES-128-ECB.
+
+**Feishu / Lark.** Create a custom app on the Feishu Open Platform
+(`open.feishu.cn/app`, or `open.larksuite.com/app` for Lark), add the Bot
+capability, grant `im:message`, `im:message:send_as_bot`,
+`im:message.p2p_msg:readonly`, `im:message.group_at_msg:readonly` and
+`im:resource`, then enter the App ID and App Secret (and pick Feishu or Lark).
+They are verified with a tenant access token before saving. ACECode then opens
+the long connection; only while it is online does Feishu let you save **Receive
+events through persistent connection** in **Events & Callbacks**, so the connect
+step lists what to finish there: save that mode, add the event
+`im.message.receive_v1` and publish a version that includes you.
+
+**DingTalk.** Create an internal app on the DingTalk Open Platform
+(`open-dev.dingtalk.com`), add the Robot capability with **Stream** message
+mode, grant the internal robot send-message permission, publish it, and enter the
+Client ID and Client Secret. They are verified with an access token. Replies go to
+the reply address carried by each incoming message while it is valid, and through
+the robot API afterwards, so replies typed in Desktop or sent after a restart are
+still delivered.
+
+**Discord.** Create an application in the Discord Developer Portal, copy the bot
+token from the **Bot** page and turn on **Message Content Intent** there. The
+token is verified with `/users/@me` and `/applications/@me`; the connect step then
+shows an invite link with the permissions the bot needs. Direct messages and
+@mentions (or replies to the bot) in server channels and threads are handled.
+
+**LINE.** Create an official account in LINE Official Account Manager, enable the
+Messaging API, then copy the **Channel ID** and **Channel secret** from LINE
+Developers; ACECode issues short-lived access tokens from them (a long-lived token
+can be pasted instead). In the console turn on **Use webhook** and turn off
+auto-reply and greeting messages. LINE can only deliver to a public HTTPS address,
+so the daemon starts a separate listener bound to `127.0.0.1` that serves only
+the webhook, a health check and outgoing media; every request is checked against
+the channel secret signature. Leave **Public address** empty to let ACECode start
+a Cloudflare quick tunnel (`cloudflared`, install with
+`winget install --id Cloudflare.cloudflared`) or enter your own HTTPS address that
+forwards to that listener. On every connect ACECode sets LINE's webhook URL to the
+current address. Never point a tunnel at the daemon's own port: local requests
+skip token checks there.
+
+**Feishu, DingTalk, Discord and LINE owners.** After connecting, the owner step
+shows a 6-digit code. Send exactly those digits to the bot in a private chat; the
+sender becomes the owner and the message is not passed to a session. Codes expire
+after ten minutes and work once; ten wrong guesses while a code is outstanding
+invalidate it.
+
+### Access
+
+Everyone is denied by default. Each platform has at most one owner.
+
+- An unknown private sender gets a pairing notice. The request appears on the
+ platform's card, Desktop shows a system notification when its window is not
+ focused, and the request expires after ten minutes. Approval applies from the
+ contact's next message. Approving a private user while there is no owner makes
+ them the owner.
+- A group must be approved first. In an approved group only messages that
+ @mention the bot are handled (on Telegram and Discord a reply to the bot also
+ counts), and each member is approved separately. QQ member identities are
+ scoped per group. On Discord each server channel or thread counts as a group;
+ WeChat has no groups.
+- Revoking access stops processing that contact immediately and aborts the
+ running turn of their sessions.
+- Switching to a different bot clears the owner and approvals on QQ, WeChat,
+ Feishu, DingTalk and LINE, because their user ids differ per bot or app; on
+ Telegram and Discord user ids are global and are kept.
+
+### Conversations And Commands
+
+Each private chat, and each member within a group, is bound to exactly one
+ACECode session. The first accepted message creates a no-workspace session with
+`default` permissions; it never inherits the daemon's dangerous or
+skip-confirmation mode. Bindings survive restarts. A session is bound to at most
+one chat: resuming it from another chat, even on the other platform, moves the
+binding and tells the previous chat. If a bound session was permanently deleted,
+the bot says so and suggests `/new` or `/sessions` instead of silently starting a
+new one.
+
+| Command | Effect |
+| --- | --- |
+| `/help` | Commands available to the sender |
+| `/status` | Platform, session, location, model, busy state, pending approvals and questions |
+| `/stop` | Stop the current turn |
+| `/new` | New session in the bound session's workspace, or a no-workspace session |
+| `/sessions`, `/sessions more`, `/sessions search ` | Numbered list of sessions to switch to |
+| `/resume ` | Switch the binding; numbers refer to the latest list |
+| `/model [name]` | List saved models, or switch the bound session's model from the next turn |
+| `/approve `, `/deny ` | Decide a permission request once |
+| `/aq ...` | Answer an AskUserQuestion, with the same syntax as `/rc` |
+
+Commands are never sent to the model. While a session is busy `/new` and
+`/resume` are refused; other commands run at once and plain messages queue as
+usual. The owner can switch to any normal, unarchived session, including other
+workspaces and no-workspace sessions. Other contacts only see sessions created
+from their own chat. Archived and subagent sessions are never listed. Text that
+starts with `/` is expanded as an opencode or skill command exactly like Web
+input. Plain text sent while a question is pending is handed to that question as
+an interjection.
+
+### Output And Media
+
+The assistant text of each turn and turn errors are sent; tool progress is not.
+Long text is split below each platform's limit. QQ merges one turn's text into as
+few messages as possible and uses Markdown with a plain-text fallback. Telegram
+converts Markdown to Telegram HTML and shows "typing" while the session is busy.
+Replies to input typed in Desktop are sent to the bound chat as well. Permission
+requests list `/approve` and `/deny` ids and questions use `/aq`; the first
+answer, from the chat or Desktop, wins and the other side is told it was handled.
+Send failures are counted per binding and written to the daemon log. A send
+whose outcome is unknown is never retried automatically.
+
+QQ passive replies quote the incoming message (private chats: 4 replies within 60
+minutes; groups: 5 within 5 minutes). Beyond that window ACECode sends an active
+message; if that is refused too, the output is held (up to 20 per chat, in memory)
+and delivered before the contact's next message is handled.
+
+Feishu sends Markdown as rich text (`post`) and falls back to plain text;
+DingTalk sends Markdown; Discord sends Markdown as is, below 2000 characters per
+message, never pinging @everyone; WeChat and LINE send plain text. LINE uses the
+free reply token while it is fresh and push messages otherwise; when the push
+quota is used up, outputs are held until the contact's next message, like QQ.
+
+Images and files up to 25 MiB, and within the platform download limit (20 MB on
+Telegram), are imported into the session's attachments. QQ voice messages use the
+platform's recognized text. Video, stickers and voice without text get a clear
+"not supported" reply. Images and files produced by the session are sent as
+native media: on Telegram images over 10 MB go as files and files over 50 MB are
+not sent; on QQ files over 20 MiB are replaced by a text notice. LINE bots
+cannot send files at all, so a text notice is sent instead; LINE images are served
+to LINE through the listener's media address.
+
+### Runtime And Storage
+
+Only the daemon hosts these channels: Desktop's shared daemon or a standalone
+`acecode daemon`. The TUI and headless modes never do. Whether channels stay
+online after Desktop quits follows **Settings > General > Continue running the
+background process after exiting ACECode**. Each platform account has its own OS
+file lock (`owner.lock`); a second process stays in standby, shows that the bot
+is held by another ACECode on the card (the owning PID goes to the log) and takes
+over after that process exits.
+
+Data lives under `/channels//` (`qq`, `weixin`, `feishu`,
+`dingtalk`, `telegram`, `discord`, `line`).
+`config.json` holds the on/off state, credentials, owner and approvals; it has
+owner-only permissions and is written only through the settings API. `state.json`
+holds bindings, the sessions created per chat, the last 4096 message receipts and
+the Telegram update offset and the WeChat polling cursor. A corrupt file blocks that platform with an error
+instead of being reset. Credentials and QR payloads are never logged; the API
+returns only their last four characters. The settings API and its WebSocket events
+accept direct local clients only (`docs/daemon-api.md`, section 19).
+
+### Limits
+
+QQ guild channels, Telegram webhooks, hosted bots, buttons, streamed message
+editing and voice replies are not supported. Feishu and DingTalk apps are set up
+by hand (their scan-to-create flows are not used). Cloudflare quick tunnels have
+no uptime guarantee and get a new address on every start; LINE may lose messages
+while its webhook URL switches. WeChat logins expire on Tencent's side and must
+be scanned again. Feishu and DingTalk hand each event to one random connection
+of the app, so running the same app in another ACECode, hermes or OpenClaw
+silently takes part of the messages; use one app per runner. LINE shows
+"connecting" until **Use webhook** is on in the console, and LINE file
+messages other than images become a text notice. Discord forum channels and
+Feishu card buttons are not handled. QQ "typing" is not sent because it
+may consume the passive reply budget. Held QQ outputs are lost if the daemon
+restarts; the session transcript still has them. Chunked QQ uploads are not
+implemented. So far the channels have been verified on Windows only.
+
+### Build And Local Tests
+
+```sh
+cmake --build --target acecode acecode_unit_tests
+acecode_unit_tests --gtest_filter='WebSocketClient*:AesGcm*:Im*:Qq*:Tg*:Telegram*:Channel*:ImChannelsEndToEnd*'
+pnpm --dir web test
+```
+
+The transports run end to end against local fake QQ (token, gateway WebSocket,
+messages, uploads, bind portal) and Telegram (Bot API, long polling, multipart)
+servers; the core uses an in-memory session client. `ImChannelsEndToEnd*` runs the daemon's
+production wiring with a real session registry and a scripted model against those fakes, covering
+owner binding, private and group chats, approvals, session switching, media and QQ resend. These
+tests never contact
+Tencent or Telegram. Real-account acceptance is tracked in the change's
+`verification.md`.
+
+## WhatsApp
-The first channel is a personal WhatsApp account linked by QR. C++ routing lives
+WhatsApp uses a personal account linked by QR. C++ routing lives
in `src/host/channels`; a small, pinned Baileys/Node.js process handles WhatsApp device
authentication and encryption. This is an unofficial WhatsApp Web integration,
not the Business Cloud API. Account-based end-to-end validation requires pairing
your own device. Never expose the account's auth directory or pairing QR.
-## Configuration
+### Configuration
Run **`acecode channels`** in an interactive terminal. `acecode channels setup`
is an explicit alias. This command only configures the account; it does not
@@ -66,7 +323,7 @@ already authorized by the wizard. Bot output IDs are persisted to avoid self-cha
echo loops. Existing contacts remain authorized when the wizard is rerun; revoke
unwanted contacts explicitly with `acecode channels revoke `.
-## Conversations And Controls
+### Conversations And Controls
The first authorized message creates a regular **no-workspace session**, with
`default` tool permissions. It does not inherit a local skip-confirmations mode,
@@ -124,7 +381,7 @@ message reference while it remains in the bounded bridge cache. Long text is
split using the existing RC helper. Unsupported media and delivery failures do
not terminate other conversations.
-## Runtime And Recovery
+### Runtime And Recovery
Only the daemon worker hosts the gateway, using its existing SessionRegistry and
LocalSessionClient. Starting `acecode daemon` connects configured channels.
@@ -166,7 +423,7 @@ No voice, scheduled/cross-channel delivery, other platforms, Matrix encryption,
Yuanbao, Signal, BlueBubbles or Photon support is included in this phase. Existing
`/rc` activation and bindings are unchanged.
-## Build And Local Tests
+### Build And Local Tests
`acecode` builds stage the bridge next to the executable. `cmake --install build
--config Release --prefix --component whatsapp_channel` stages the
@@ -191,7 +448,7 @@ test loads Baileys and checks the disconnected protocol without contacting an
account. These are not a substitute for scanning a QR and exchanging real
messages from the intended account.
-## Real Account Acceptance
+### Real Account Acceptance
After pairing, verify the following with the intended account before relying on
unattended operation:
diff --git a/docs/daemon-api.md b/docs/daemon-api.md
index b916a3287..d3df6db23 100644
--- a/docs/daemon-api.md
+++ b/docs/daemon-api.md
@@ -138,6 +138,8 @@ Session list endpoints return arrays of objects shaped like:
"swarm_mode": "off",
"created_at": "2026-07-04T01:23:45Z",
"updated_at": "2026-07-04T01:25:00Z",
+ "last_user_message_at": "2026-07-04T01:24:00Z",
+ "last_turn_outcome": "completed",
"provider": "openai",
"model": "gpt-4.1",
"model_name": "work-gpt",
@@ -168,7 +170,11 @@ Session list endpoints return arrays of objects shaped like:
```
Some fields are omitted when empty, especially `worktree`, `todos`, and token
-usage. For a managed-worktree session, top-level `cwd` intentionally remains
+usage. `remote_control_bound` is `true` only for the session bound by `/rc`.
+A session bound by an IM channel conversation (QQ / Telegram, section 19) also
+carries `"channel_bound": {"platform": "qq"}`; unbound sessions omit the field.
+The sidebar shows the same computer icon for either, but `/rc`-specific
+behavior keys only on `remote_control_bound`. For a managed-worktree session, top-level `cwd` intentionally remains
the workspace/session-storage root. `worktree.path` is the active absolute
working root used by file, Git, LSP, and path-reference surfaces.
@@ -272,6 +278,7 @@ update their transcript presentation.
| GET | `/api/pinned-sessions/order` | read global cross-scope pin order |
| PUT | `/api/pinned-sessions/order` | set global cross-scope pin order |
| GET | `/api/sessions` | compatibility session list |
+| GET | `/api/desktop-office?session=&workspace=` | recent five offices and selected root/agent runtime snapshot |
| GET | `/api/session-search/sessions?q=...&limit=N&cursor=...&request_id=...` | incremental global session catalog page |
| GET | `/api/session-search/user-messages?q=...&limit=N&request_id=...` | advance one bounded visible-user-message search batch |
| POST | `/api/session-search/requests/:request_id/cancel` | cancel/pause an incremental global search |
@@ -442,6 +449,14 @@ update their transcript presentation.
| POST | `/api/pty/:id/resize` | resize PTY |
| POST | `/api/pty/:id/title` | set PTY title |
| PUT | `/api/console/config` | write console shell config |
+| GET | `/api/channels` | IM channel snapshots (QQ, WeChat, Feishu, DingTalk, Telegram, Discord, LINE); local clients only |
+| POST | `/api/channels/:platform/enabled` | connect or disconnect a channel immediately |
+| PUT | `/api/channels/:platform/credentials` | validate, then save channel credentials |
+| POST / DELETE | `/api/channels/:platform/bind` | start or cancel scan-to-configure (QQ, WeChat) |
+| POST | `/api/channels/:platform/owner-link` | one-time owner link (Telegram) or 6-digit owner code (Feishu, DingTalk, Discord, LINE) |
+| POST | `/api/channels/telegram/remove-webhook` | remove a Telegram webhook after confirmation |
+| POST | `/api/channels/:platform/requests/:id/{approve,reject}` | decide a pairing request |
+| DELETE | `/api/channels/:platform/access/:principal` | revoke a contact or group |
`POST /api/config/model-order` requires authentication and a complete permutation of
the current model names returned by `GET /api/models`. Legacy profiles belonging
@@ -545,6 +560,32 @@ disabled, the write is silently ignored.
## 5. Workspaces and Sessions
+### `GET /api/desktop-office?session=&workspace=`
+
+只读桌面办公室快照,使用通用鉴权/CORS,响应 `Cache-Control: no-store`。
+`session` 与 `workspace` 可省略;默认选最近办公室。选择子会话时返回它所属的根办公室。
+显式选择不存在或已归档会话返回 `404`,并在可用时附带当前 `offices`。
+
+```json
+{
+ "offices": [{"id":"root","workspace_hash":"abc123","title":"Review", "last_user_message_at":"2026-10-06T10:00:00Z"}],
+ "selected": {"id":"root","active":true,"status":"idle","last_turn_outcome":"completed","activity":{"known":true,"busy":false,"outcome":"completed","phase":"","tool":"","seq":42}},
+ "agents": [{"id":"root"}],
+ "complete": true,
+ "connected": true
+}
+```
+
+`offices` 最多五个:跨工作区按 `last_user_message_at` 降序,排除归档、子会话和没有真实用户输入的会话。打开会话、模型输出、隐藏提示及 agent 间消息不推进此时间;旧记录从 JSONL 逆向读取后缓存。目录或旧记录仍在加载时 `complete=false`,客户端可继续读取;空目录返回空数组及 `selected:null`。分叉继承保留消息的原始时间。
+
+`selected` 和 `agents` 只传身份、标题、所属工作区、真实状态和当前上下文用量等展示字段;不含输入草稿、原始工具参数、工具输出或历史正文(下文 `activity.detail` / `activity.text` 两个有界展示片段除外)。`agents` 包含根会话和当前 daemon 已装载的直属/网状子会话;网状子会话的 `parent_session_id` 是树根,`agent_path` 保留实际层级。已成功结束的子会话可仍出现在原始快照中,由桌宠投影立即离座。标题/摘要限 240 UTF-8 字节。
+
+运行中条目的 `activity` 来自事件分发器维护的紧凑状态,不依赖有限的重放缓冲:`known`、`busy`、`turn_id`、`outcome`、`phase`、`label`、`tool`、`detail`、`text`、`seq`、`compact_id`、`transfers`。待授权/待回答优先于工具和模型阶段;`outcome` 保留 `completed`/`aborted`/`error` 差别。`transfers` 最多保留 16 个带序号的 agent 间交接标识,不含消息内容。持久化 `last_turn_outcome` 使恢复后的完成主会话继续显示睡眠状态。
+
+流式正文会把 `phase` 置为 `responding`(默认「用于编程」模式下服务端不发该进度,由 token 事件推断),推理片段对应 `reasoning`,`text` 是本段正文或推理最后不超过 160 UTF-8 字节的尾巴,换阶段或开始工具调用即清空;`detail` 是正在运行工具的一行调用预览(`display_override`,为空时取 `command_preview`),最多 120 字节,工具结束清空。桌宠页面在两次快照之间直接用 WebSocket 的 `token` / `reasoning` 事件按 `seq` 叠加实时文字(约 150ms 节流),快照追上后以快照为准。
+
+`token_usage` 是最近请求上下文统计,配合 `context_window` 计算文件堆厚度;未知窗口不伪造比例。休眠会话可通过已有 model 端点补充窗口大小。该端点不会发起模型请求或改变会话运行状态。
+
### `GET /api/workspaces`
Returns `Workspace[]`. The registry is scanned before listing. If no registry
@@ -5458,7 +5499,7 @@ discarded. The original history remains available.
The daemon also hosts the optional WhatsApp channel runtime. Its control plane
is a separate private loopback listener, not a route on this Web API. See
-[WhatsApp channels](channels.md) for standalone CLI configuration and management. The descriptor
+[WhatsApp channels](channels.md#whatsapp) for standalone CLI configuration and management. The descriptor
`channels/whatsapp/owner.json` in the ACECode data directory contains protocol
version 1, PID, port and an owner token. `POST /channels` requires the
`X-ACECode-Channels-Token` header even on loopback and rejects browser Origin
@@ -5483,6 +5524,167 @@ from older binaries. Explicit CLI runtime commands never start
a missing host. `acecode channels status` can read saved settings without one;
its `host_running` field distinguishes offline configuration from a live owner.
+## 19. IM Channels (QQ, WeChat, Feishu, DingTalk, Telegram, Discord, LINE)
+
+`openspec add-desktop-im-channels`. The daemon hosts native channels for QQ
+(official bot, API v2 WebSocket gateway), WeChat (`weixin`, Tencent iLink bot,
+long polling), Feishu / Lark (`feishu`, long-connection WebSocket), DingTalk
+(`dingtalk`, Stream mode), Telegram (Bot API long polling), Discord (gateway
+WebSocket) and LINE (`line`, Messaging API webhooks through a dedicated local
+listener). Platform names in paths are exactly these keys. Each platform keeps
+its data under `/channels//`: `config.json`
+(enabled flag, credentials, owner, approved contacts and groups; written only by
+these endpoints, owner-only permissions) and `state.json` (session bindings,
+sessions created per IM conversation, de-duplication receipts, Telegram update
+offset). A corrupt file blocks that platform with state `error`; it is never
+reset silently. WhatsApp (section 18) is unaffected and is not managed here.
+
+All routes accept only direct local clients. Requests forwarded by the remote
+Web proxy (source `127.0.0.2`) or from any other address return
+`403 {"error":"LOCAL_ONLY"}` even with a valid token. Errors use
+`{"error": CODE, "message": text}`: `400 CHANNEL_ERROR` or `BAD_REQUEST`
+(messages are user-facing Chinese text without credentials),
+`404 UNKNOWN_PLATFORM`, and `503 UNAVAILABLE` when the daemon has no channel host.
+Mutating routes return the full `GET /api/channels` snapshot unless noted.
+
+### `GET /api/channels`
+
+```json
+{
+ "platforms": [
+ {
+ "platform": "telegram",
+ "configured": true,
+ "enabled": true,
+ "state": "connected",
+ "detail": "",
+ "retry_stopped": false,
+ "account": "123456",
+ "display_name": "@ace_bot",
+ "extra": {"username": "ace_bot", "privacy_mode": true, "link": "https://t.me/ace_bot"},
+ "credential_hint": "****wxyz",
+ "owner": "user:42",
+ "contacts": [
+ {"principal": "user:42", "kind": "user", "name": "Me", "owner": true, "approved_at_ms": 1790000000000}
+ ],
+ "pending": [
+ {"id": "a1b2c3d4", "kind": "user", "name": "Alice", "principal": "user:7",
+ "label": "Telegram 私聊", "expires_in_s": 512}
+ ],
+ "owner_window": false,
+ "bindings": [
+ {"key": "[\"telegram\",\"123456\",\"private\",\"42\"]", "label": "Telegram 私聊",
+ "chat": "42", "sender": "42", "session_id": "sid", "no_workspace": true,
+ "cwd": "...", "workspace_hash": "", "sent": 3, "failed": 0, "dropped": 0}
+ ]
+ }
+ ],
+ "binds": {"qq": {"platform": "qq", "phase": "idle"}, "weixin": {"platform": "weixin", "phase": "idle"}}
+}
+```
+
+- `state`: `unconfigured`, `disabled`, `connecting`, `connected`, `retrying`,
+ `failed` (`retry_stopped=true` after authentication failure, ban or another
+ fatal close; turning the switch on again reconnects), `standby` (another
+ ACECode process owns the account lock; `hosted_by_pid` names it, and this
+ process takes over after that owner exits), `stopped`, or `error` (corrupt
+ data files; `detail` gives the reason).
+- `credentials_public` lists the saved credential fields: non-secret values as
+ stored (AppID, Client ID, bot ids, Feishu `domain`, LINE `public_url`) and
+ secrets masked to their last four characters. `credential_hint` is the mask of
+ the platform's first secret field. QQ snapshots also carry `app_id`. QQ `extra`
+ reports `held` (outputs waiting for the contact's next message after the
+ passive and active reply windows were exhausted) and `markdown`.
+- Telegram `extra.webhook=true` means `getUpdates` is blocked by a configured
+ webhook; call `remove-webhook` after the user confirms.
+- Principals are `user:`, `group:`, and `member::`
+ (only QQ scopes group member ids to the group). On WeChat only private chats
+ exist; on Discord a guild channel (or thread) is the "group".
+- The page shows only a one-line status. Failure reasons, privacy mode, held
+ outputs, bindings and send counts are written to the daemon log under
+ `[channels/]`.
+
+### `POST /api/channels/:platform/enabled`
+
+Body `{"enabled": true|false}`. The switch persists immediately and connects or
+disconnects without a separate save. Enabling an unconfigured platform returns
+`400`. Disabling keeps credentials, contacts and bindings.
+
+### `PUT /api/channels/:platform/credentials`
+
+Accepted fields per platform: QQ `app_id`, `app_secret`; Feishu `app_id`,
+`app_secret`, `domain` (`feishu` or `lark`); DingTalk `client_id`,
+`client_secret`, `robot_code`; Telegram `token`; Discord `token`; LINE
+`channel_id`, `channel_secret`, `access_token` (optional long-lived token),
+`public_url` (optional). WeChat has no manual credentials (scan only). Values
+are trimmed strings of at most 512 bytes; unknown fields are rejected. An empty
+or missing required field keeps the saved value (the page only knows the masked
+hint); an empty optional field such as `public_url` clears it. The merged
+credentials are validated online first (QQ access-token exchange, Feishu tenant
+token, DingTalk access token, Telegram `getMe`, Discord `/users/@me` plus
+`/applications/@me`, LINE token issue plus `/v2/bot/info`); failure returns
+`400` and saves nothing. Validation may add derived non-secret fields (Discord
+`bot_id` and `application_id`, LINE `bot_id`). On success the platform
+reconnects if it is on. Switching to a different bot account drops bindings of
+the old bot, and on platforms whose user ids are scoped per bot (QQ, WeChat,
+Feishu, DingTalk, LINE) also clears the owner and approved contacts.
+
+### `POST /api/channels/:platform/bind` and `DELETE /api/channels/:platform/bind`
+
+Scan-to-configure for `qq` and `weixin` (other platforms return `404`). Starts
+the flow and returns `{"platform":
,"phase":"starting"}`; a second start while
+any scan is running returns `400`. Progress is pushed as `channels_bind` events
+(below). `DELETE` cancels and returns the current bind state. Cancellation,
+failure and timeout leave the existing configuration untouched. On success the
+credentials are saved (QQ: AppID and the locally decrypted AppSecret; WeChat:
+bot token, bot id and API base), the scanning user becomes owner when the
+service reports them (WeChat always does; on QQ otherwise the first private
+sender within 10 minutes does), and the platform turns on.
+
+### `POST /api/channels/:platform/owner-link`
+
+Requires a connected bot and no owner yet. Telegram returns
+`{"link":"https://t.me/?start=","expires_in_s":600}`; the first user
+who opens the link and sends `/start ` becomes owner. Feishu, DingTalk,
+Discord and LINE return `{"code":"123456","expires_in_s":600}`; the first user
+who sends exactly these six digits in a private chat with the bot becomes owner,
+and that message is not passed to a session. Codes are single-use; while a code
+is outstanding, ten wrong six-digit guesses in private chats invalidate all
+outstanding codes. QQ and WeChat return `400` (owners come from scanning).
+
+### `POST /api/channels/telegram/remove-webhook`
+
+Calls Telegram `deleteWebhook` and resumes polling. Only call it after the user
+confirms; it replaces whatever other service registered the webhook.
+
+### `POST /api/channels/:platform/requests/:id/approve|reject`
+
+Decides a pending pairing request (requests expire after 10 minutes; an
+expired or unknown id returns `400`). Approving a private user while the
+platform has no owner makes that user the owner. Approval applies from the
+contact's next message; the message that triggered the request is not replayed.
+
+### `DELETE /api/channels/:platform/access/:principal`
+
+The principal is percent-encoded in the path (for example
+`member%3AG1%3AM1`). Revoking removes it from the approved list (clearing the
+owner if it matches), drops its pending requests, and aborts the running turn
+of every session bound to it.
+
+### WebSocket events
+
+Channel events are sent only to WebSocket connections opened by direct local
+clients, using the envelope `{"type", "timestamp_ms", "payload"}` without a
+session id:
+
+| Type | Payload |
+|---|---|
+| `channels_state` | one platform snapshot, same shape as an item of `platforms` |
+| `channels_bind` | `{"platform":"qq"/"weixin", "phase", "qr_url"?, "refreshes"?, "error"?, "account"?, "owner_bound"?}`; phase is `starting`, `waiting`, `completed`, `failed`, `cancelled` or `timed_out` |
+| `channels_request` | `{"platform","id","kind","name","label"}` for a new pairing request; Desktop shows a system notification |
+
+`qr_url` is the QR code content for the phone; it is never logged.
+
## Session loading diagnostics
- `POST /api/diagnostics/session-open` accepts `session_id`, nonnegative
diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md
new file mode 100644
index 000000000..ba9d41ae6
--- /dev/null
+++ b/docs/desktop-agent-office.md
@@ -0,0 +1,28 @@
+# Agent 桌面办公室
+
+Windows 与 macOS Desktop 会显示一个透明的像素办公室。单会话有一个主角色 Maestro,多 agent 任务按真实子会话入座;无需启动额外模型,也没有演示任务循环。美术与拖动缩放来自原有 Claude 桌宠,旧的 `design-prototypes/agent-office` 事务所原型已移除。
+
+## 操作
+
+- 顶部显示跨工作区最近发过消息的五个主会话。顺序只看用户最后发送消息的时间,打开、切换和 agent 输出不会把会话顶到最前面。
+- “跟随”默认开启,办公室随主窗口当前会话切换。点击门牌会关闭跟随并固定办公室;再次打开跟随后恢复跟随。查看最近五个之外的当前会话不改变门牌排序。
+- 主 agent 成功完成后留在座位显示 `zzz`,下一轮自动醒来;成功完成的子 agent 立即离座,重新工作时以同样的外观返回。
+- 忙碌的 agent 头顶常驻状态气泡:「图标 动作 · 细节」。正在输出时显示「撰写回复」和最新的几个字(打字动画 + 光标),推理时显示思考点和推理尾巴,调用工具时显示动作(读取、运行命令、修改代码…)和调用预览(路径、命令)。默认「用于编程」模式下服务端不发正文进度,桌宠由 token 事件推断。气泡互相避让:先左右错开,再抬高并用引线连回头顶,放不下时只留动作。
+- 子 agent 出现时从门口走进来,先到派活的人桌前领「任务」信封再回工位;完成后走到派活的人桌前递「回报」信封(派活的人显示「收到…的结果」)再出门;失败或停止的成员直接出门。网状协作中 agent 间消息以带「消息 / 新任务 / 回报」标签的信封在工位间飞过。系统开启「减少动态效果」时不走路,交接信封仍然显示。
+- 等待授权、等待回答、重试、压缩、失败和取消有各自状态。超过七个子角色时点击 `+N` 查看成员并打开对应会话;列表随状态更新,Escape 收起。选中房间人物后双击可打开其会话。
+- 文件堆随当前上下文占用增长,压缩后依据实际新统计变薄。工具符号与聊天工具行共用 SVG 来源。
+- 拖动房间空地移动窗口;滚轮、右下角把手和右键菜单可缩放。窗口不抢主界面焦点,透明角落可穿透点击;尺寸按原实现保存。
+
+## 构建与数据流
+
+由当前工作区的 `scripts/dev_desktop.bat` 启动开发版。先构建 `web/`,再配置/构建 CMake Desktop,使内嵌主界面与桌宠资源保持一致。`ACECODE_DESKTOP_PET=0` 可对本次进程禁用桌宠;Linux 当前没有原生桌宠窗口实现。
+
+macOS 版(`src/apps/desktop/desktop_pet_mac.mm`,ARC 编译)用非激活 NSPanel + WKWebView 承载同一份页面:文档开始前注入 `window.chrome.webview` 垫片,页面代码不分平台;透明区域的点击穿透靠定时检测鼠标是否落在房间、控制条或页面上报的浮层里,再切换 `ignoresMouseEvents`;拖动与把手缩放由定时器跟随鼠标。默认 1.25 倍(约 430×315 点),设置存 `~/Library/Application Support/ACECode/desktop-pet/settings.json`。
+
+数据链为:会话元数据/事件分发器 → `GET /api/desktop-office` → App 级办公室控制器 → 原生 WebView 桥 → 像素场景。控制器复用现有引用计数 WS 订阅,事件触发刷新并以 3 秒轮询兜底;正在加载旧记录时短间隔继续读取。快速切换以请求代次和取消信号隔离迟到响应;页面卸载释放订阅和计时器,重载从原生缓存恢复当前选择与跟随状态。
+
+页面把成员列表、提示和状态气泡的矩形(节流后)上报给原生窗口:Windows 的窗口区域同时裁剪绘制,冒出墙顶的气泡必须并入区域才不会被切掉;macOS 用同一份矩形判断点击穿透。
+
+断线时明确显示正在重连,恢复后重取真实快照。错误和取消不会被当作成功睡眠。只同步小型展示字段,不通过动画触发或控制 agent 任务。
+
+视觉规则见 [桌宠设计](../assets/desktop_pet/DESIGN.md),协议见 [daemon API](daemon-api.md)。验收命令及本次实测范围见 [交付记录](../openspec/changes/integrate-desktop-agent-office/verification.md)。
diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py
index d0a0405db..7bb2bc59c 100644
--- a/docs/help-source/group5.py
+++ b/docs/help-source/group5.py
@@ -55,7 +55,15 @@
'''
show 展示当前连接、入站与出站状态,内容可能包含认证信息,不要直接公开截图。off 解除当前绑定。daemon 托管模式会保存绑定会话并在后续启动时尝试恢复,单纯关闭桌面窗口不等于主动解绑。
手动集成使用 /remote-control on 启动本地入站服务,再通过 /remote-control url <webhook-url> 指定出站地址。入站使用 POST /rc/send、X-ACECode-RC-Token 请求头和 {"text":"消息"} 正文;按 show 的实际地址接入。