@lelouchhe/webagent 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{b as r,c as i}from"./chunk.UMQMOGWO.js";import"./chunk.AJZBJBMO.js";localStorage.getItem(r)&&location.replace("/");var s=document.getElementById("login-form"),n=document.getElementById("token-input"),o=document.getElementById("error"),t=document.getElementById("submit-btn");function u(e){o&&(o.textContent=e,o.style.display=e?"block":"none")}s&&n&&t&&(s.addEventListener("submit",e=>{e.preventDefault(),u(""),t.disabled=!0,t.textContent="Verifying\u2026",(async()=>{try{let l=await i(n.value);l.ok?location.replace("/"):u(l.error)}finally{t.disabled=!1,t.textContent="Sign in"}})()}),n.focus());
1
+ import{b as r,c as i}from"./chunk.3CLGCUHW.js";import"./chunk.AJZBJBMO.js";localStorage.getItem(r)&&location.replace("/");var s=document.getElementById("login-form"),n=document.getElementById("token-input"),o=document.getElementById("error"),t=document.getElementById("submit-btn");function u(e){o&&(o.textContent=e,o.style.display=e?"block":"none")}s&&n&&t&&(s.addEventListener("submit",e=>{e.preventDefault(),u(""),t.disabled=!0,t.textContent="Verifying\u2026",(async()=>{try{let l=await i(n.value);l.ok?location.replace("/"):u(l.error)}finally{t.disabled=!1,t.textContent="Sign in"}})()}),n.focus());
@@ -1 +1 @@
1
- import{b as g,d as h,m as y,o as T,p as C}from"./chunk.CT5WBNGZ.js";import{a as p}from"./chunk.UMQMOGWO.js";import"./chunk.AJZBJBMO.js";var $=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function f(t){return String(t).padStart(2,"0")}function E(t,n){if(!t)return"";let s=new Date(t),i=s.getTime();if(isNaN(i))return"";let l=Math.floor((n.getTime()-i)/1e3);if(l<60)return"just now";let c=Math.floor(l/60);if(c<60)return`${c}m ago`;let o=Math.floor(c/60);if(o<24)return`${o}h ago`;let e=Math.floor(o/24);if(e<7)return`${e}d ago`;let m=s.getUTCFullYear()===n.getUTCFullYear(),r=$[s.getUTCMonth()],a=s.getUTCDate();return m?`${r} ${a}`:`${r} ${a}, ${s.getUTCFullYear()}`}function _(t){if(!t)return"";let n=new Date(t);return isNaN(n.getTime())?"":`${n.getUTCFullYear()}-${f(n.getUTCMonth()+1)}-${f(n.getUTCDate())} ${f(n.getUTCHours())}:${f(n.getUTCMinutes())} UTC`}function S(t){let n=/^\/api\/v1\/sessions\/[^/]+\/attachments\/([A-Za-z0-9._-]+)(?:\?.*)?$/;return s=>{let i=n.exec(s);return i?`/s/${encodeURIComponent(t)}/attachments/${i[1]}`:s}}function w(t){if(typeof t.data=="string")try{return JSON.parse(t.data)}catch{return{}}return t.data}function x(t,n,s,i){if(t.type!=="assistant_message"||n?.type!=="assistant_message"||t.seq!==n.seq+1||!s?.classList.contains("assistant"))return!1;let l=(s.getAttribute("data-raw")??"")+(typeof i.text=="string"?i.text:"");return s.setAttribute("data-raw",l),C(s,l,void 0,!0),g(s),!0}function v(t,n,s){let i=new Set;for(let r of t)if(r.type==="permission_response")try{let a=JSON.parse(r.data);typeof a.requestId=="string"&&i.add(a.requestId)}catch{}let l=new Map,c=new Map,o=null,e=null,m={rewriteAttachmentSrc:S(s),enhanceMarkdown:g,findToolCallEl:r=>l.get(r)??null,findPermissionEl:r=>c.get(r)??null,findBashEl:()=>o,isPermissionResolved:r=>i.has(r),finalAnswerToolText:null};for(let r=0;r<t.length;r++){let a=t[r];if(!y(a.type))continue;let u=w(a),A=r>0?t[r-1]:null,M=n.lastElementChild;if(x(a,A,M,u)){e=null;continue}m.finalAnswerToolText=a.type==="assistant_message"?e:null;let d=T(a.type,u,m);d?(n.appendChild(d),a.type==="tool_call"&&typeof u.id=="string"?l.set(u.id,d):a.type==="permission_request"&&typeof u.requestId=="string"?c.set(u.requestId,d):a.type==="bash_command"&&(o=d)):a.type==="bash_result"&&(o=null),a.type==="tool_call_update"?e=h(typeof u.status=="string"?u.status:"",Array.isArray(u.content)?u.content:void 0):e=null}}async function U(){try{let e=localStorage.getItem("theme")??"auto";document.documentElement.setAttribute("data-theme",e)}catch{}let t=/^\/s\/([A-Za-z0-9_-]{24})(?:[/?#]|$)/.exec(location.pathname);if(!t){document.body.textContent="invalid share URL";return}let n=t[1],s=document.getElementById("session-info"),i=document.getElementById("messages"),l=document.querySelector(".share-footer-author"),c=document.querySelector(".share-footer-meta");if(!i)return;let o;try{let e=await fetch(`/api/v1/shared/${encodeURIComponent(n)}/events`,{headers:{Accept:"application/json"},credentials:"omit"});if(e.status===p.GONE){document.body.innerHTML="<div class='share-gone'>\u6B64\u94FE\u63A5\u5DF2\u64A4\u9500\u6216\u8FC7\u671F\u3002</div>";return}if(!e.ok){document.body.textContent=`error ${e.status}`;return}o=await e.json()}catch(e){document.body.textContent=`failed to load: ${String(e)}`;return}if(document.title=o.share.session_title?`${o.share.session_title} \u2014 shared`:"shared session",s&&(s.textContent=o.share.session_title??"(untitled)"),l){let e=o.share.display_name??"";l.textContent=e?`by ${e}`:""}if(v(o.events,i,n),c){let e=o.share.shared_at??o.share.created_at;c.textContent=E(e,new Date),c instanceof HTMLElement&&(c.title=_(e))}}U();
1
+ import{c as g,e as h,n as y,p as T,q as C}from"./chunk.S5LRNRJI.js";import{a as p}from"./chunk.3CLGCUHW.js";import"./chunk.AJZBJBMO.js";var $=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function f(t){return String(t).padStart(2,"0")}function E(t,n){if(!t)return"";let s=new Date(t),i=s.getTime();if(isNaN(i))return"";let l=Math.floor((n.getTime()-i)/1e3);if(l<60)return"just now";let c=Math.floor(l/60);if(c<60)return`${c}m ago`;let o=Math.floor(c/60);if(o<24)return`${o}h ago`;let e=Math.floor(o/24);if(e<7)return`${e}d ago`;let m=s.getUTCFullYear()===n.getUTCFullYear(),r=$[s.getUTCMonth()],a=s.getUTCDate();return m?`${r} ${a}`:`${r} ${a}, ${s.getUTCFullYear()}`}function _(t){if(!t)return"";let n=new Date(t);return isNaN(n.getTime())?"":`${n.getUTCFullYear()}-${f(n.getUTCMonth()+1)}-${f(n.getUTCDate())} ${f(n.getUTCHours())}:${f(n.getUTCMinutes())} UTC`}function S(t){let n=/^\/api\/v1\/sessions\/[^/]+\/attachments\/([A-Za-z0-9._-]+)(?:\?.*)?$/;return s=>{let i=n.exec(s);return i?`/s/${encodeURIComponent(t)}/attachments/${i[1]}`:s}}function w(t){if(typeof t.data=="string")try{return JSON.parse(t.data)}catch{return{}}return t.data}function x(t,n,s,i){if(t.type!=="assistant_message"||n?.type!=="assistant_message"||t.seq!==n.seq+1||!s?.classList.contains("assistant"))return!1;let l=(s.getAttribute("data-raw")??"")+(typeof i.text=="string"?i.text:"");return s.setAttribute("data-raw",l),C(s,l,void 0,!0),g(s),!0}function v(t,n,s){let i=new Set;for(let r of t)if(r.type==="permission_response")try{let a=JSON.parse(r.data);typeof a.requestId=="string"&&i.add(a.requestId)}catch{}let l=new Map,c=new Map,o=null,e=null,m={rewriteAttachmentSrc:S(s),enhanceMarkdown:g,findToolCallEl:r=>l.get(r)??null,findPermissionEl:r=>c.get(r)??null,findBashEl:()=>o,isPermissionResolved:r=>i.has(r),finalAnswerToolText:null};for(let r=0;r<t.length;r++){let a=t[r];if(!y(a.type))continue;let u=w(a),A=r>0?t[r-1]:null,M=n.lastElementChild;if(x(a,A,M,u)){e=null;continue}m.finalAnswerToolText=a.type==="assistant_message"?e:null;let d=T(a.type,u,m);d?(n.appendChild(d),a.type==="tool_call"&&typeof u.id=="string"?l.set(u.id,d):a.type==="permission_request"&&typeof u.requestId=="string"?c.set(u.requestId,d):a.type==="bash_command"&&(o=d)):a.type==="bash_result"&&(o=null),a.type==="tool_call_update"?e=h(typeof u.status=="string"?u.status:"",Array.isArray(u.content)?u.content:void 0):e=null}}async function U(){try{let e=localStorage.getItem("theme")??"auto";document.documentElement.setAttribute("data-theme",e)}catch{}let t=/^\/s\/([A-Za-z0-9_-]{24})(?:[/?#]|$)/.exec(location.pathname);if(!t){document.body.textContent="invalid share URL";return}let n=t[1],s=document.getElementById("session-info"),i=document.getElementById("messages"),l=document.querySelector(".share-footer-author"),c=document.querySelector(".share-footer-meta");if(!i)return;let o;try{let e=await fetch(`/api/v1/shared/${encodeURIComponent(n)}/events`,{headers:{Accept:"application/json"},credentials:"omit"});if(e.status===p.GONE){document.body.innerHTML="<div class='share-gone'>\u6B64\u94FE\u63A5\u5DF2\u64A4\u9500\u6216\u8FC7\u671F\u3002</div>";return}if(!e.ok){document.body.textContent=`error ${e.status}`;return}o=await e.json()}catch(e){document.body.textContent=`failed to load: ${String(e)}`;return}if(document.title=o.share.session_title?`${o.share.session_title} \u2014 shared`:"shared session",s&&(s.textContent=o.share.session_title??"(untitled)"),l){let e=o.share.display_name??"";l.textContent=e?`by ${e}`:""}if(v(o.events,i,n),c){let e=o.share.shared_at??o.share.created_at;c.textContent=E(e,new Date),c instanceof HTMLElement&&(c.title=_(e))}}U();
package/dist/login.html CHANGED
@@ -17,7 +17,7 @@
17
17
  media="(prefers-color-scheme: light)"
18
18
  />
19
19
  <script src="/theme-init.js"></script>
20
- <link rel="stylesheet" href="/styles.00etlpgs.css" />
20
+ <link rel="stylesheet" href="/styles.00nlhhf3.css" />
21
21
  </head>
22
22
  <body class="login-page">
23
23
  <div class="login-card">
@@ -44,6 +44,6 @@
44
44
  <code>webagent --create-token &lt;name&gt;</code> on the server.
45
45
  </p>
46
46
  </div>
47
- <script type="module" src="/js/login.2WA6DTGM.js"></script>
47
+ <script type="module" src="/js/login.WMURU4NI.js"></script>
48
48
  </body>
49
49
  </html>
@@ -16,10 +16,10 @@
16
16
  media="(prefers-color-scheme: light)"
17
17
  />
18
18
  <meta name="robots" content="noindex, nofollow" />
19
- <link rel="stylesheet" href="/s/_/styles.00etlpgs.css" />
19
+ <link rel="stylesheet" href="/s/_/styles.00nlhhf3.css" />
20
20
  <link rel="stylesheet" href="/s/_/share-viewer.00gubshk.css" />
21
- <link rel="modulepreload" href="/s/_/chunk.CT5WBNGZ.js">
22
- <link rel="modulepreload" href="/s/_/chunk.UMQMOGWO.js">
21
+ <link rel="modulepreload" href="/s/_/chunk.S5LRNRJI.js">
22
+ <link rel="modulepreload" href="/s/_/chunk.3CLGCUHW.js">
23
23
  <link rel="modulepreload" href="/s/_/chunk.AJZBJBMO.js">
24
24
  </head>
25
25
  <body class="share-viewer">
@@ -49,6 +49,6 @@
49
49
  >
50
50
  </div>
51
51
  </footer>
52
- <script type="module" src="/s/_/viewer.CVWXSKJM.js"></script>
52
+ <script type="module" src="/s/_/viewer.GP5VXAUY.js"></script>
53
53
  </body>
54
54
  </html>
@@ -165,7 +165,8 @@ body {
165
165
  width: 100%;
166
166
  text-align: center;
167
167
  }
168
- #theme-btn {
168
+ #theme-btn,
169
+ #file-viewer-close {
169
170
  background: transparent;
170
171
  border: none;
171
172
  color: var(--text-dim);
@@ -174,7 +175,8 @@ body {
174
175
  font-family: inherit;
175
176
  padding: 2px 6px;
176
177
  }
177
- #theme-btn:hover {
178
+ #theme-btn:hover,
179
+ #file-viewer-close:hover {
178
180
  color: var(--text);
179
181
  }
180
182
  @media (max-width: 640px) {
@@ -748,7 +750,25 @@ body {
748
750
  #status-bar::before {
749
751
  content: "\200b";
750
752
  }
753
+ #status-bar .status-model {
754
+ flex: 0 0 auto;
755
+ }
756
+ #status-bar .status-separator,
757
+ #status-bar .status-usage {
758
+ flex: 0 0 auto;
759
+ }
751
760
  #status-bar .status-cwd {
761
+ display: flex;
762
+ flex: 1 1 auto;
763
+ min-width: 0;
764
+ overflow: hidden;
765
+ }
766
+ #status-bar .status-cwd-prefix {
767
+ flex: 0 0 auto;
768
+ }
769
+ #status-bar .status-cwd-tail {
770
+ flex: 1 1 auto;
771
+ min-width: 0;
752
772
  overflow: hidden;
753
773
  text-overflow: ellipsis;
754
774
  direction: rtl;
@@ -990,6 +1010,151 @@ body {
990
1010
  cursor: default;
991
1011
  }
992
1012
 
1013
+ /* Read-only file viewer */
1014
+ #file-viewer[hidden] {
1015
+ display: none;
1016
+ }
1017
+ #file-viewer {
1018
+ position: fixed;
1019
+ inset: 0;
1020
+ z-index: 100;
1021
+ min-width: 0;
1022
+ background: var(--bg);
1023
+ color: var(--text);
1024
+ display: flex;
1025
+ flex-direction: column;
1026
+ }
1027
+ body.file-viewer-open {
1028
+ overflow: hidden;
1029
+ }
1030
+ .file-viewer-header {
1031
+ min-height: 41px;
1032
+ padding: 6px 16px;
1033
+ background: var(--surface);
1034
+ border-bottom: 1px solid var(--border);
1035
+ display: flex;
1036
+ align-items: center;
1037
+ gap: 12px;
1038
+ font-size: 15px;
1039
+ }
1040
+ #file-viewer-path {
1041
+ min-width: 0;
1042
+ flex: 1;
1043
+ overflow: hidden;
1044
+ text-overflow: ellipsis;
1045
+ white-space: nowrap;
1046
+ direction: ltr;
1047
+ text-align: left;
1048
+ color: var(--text-dim);
1049
+ font-size: inherit;
1050
+ }
1051
+ #file-viewer-close {
1052
+ flex: 0 0 auto;
1053
+ }
1054
+ #file-viewer-notice {
1055
+ flex: 0 0 auto;
1056
+ padding: 6px 14px;
1057
+ border-bottom: 1px solid var(--border);
1058
+ color: var(--yellow);
1059
+ font-size: 13px;
1060
+ }
1061
+ #file-viewer-notice[hidden] {
1062
+ display: none;
1063
+ }
1064
+ #file-viewer-content {
1065
+ flex: 1 1 auto;
1066
+ min-height: 0;
1067
+ overflow: auto;
1068
+ padding-bottom: env(safe-area-inset-bottom);
1069
+ }
1070
+ .file-viewer-markdown {
1071
+ width: 100%;
1072
+ min-height: 100%;
1073
+ }
1074
+ .file-viewer-image {
1075
+ display: block;
1076
+ max-width: 100%;
1077
+ max-height: 100%;
1078
+ margin: auto;
1079
+ padding: 12px;
1080
+ object-fit: contain;
1081
+ }
1082
+ .file-viewer-code-scroll {
1083
+ min-height: 100%;
1084
+ min-width: max-content;
1085
+ }
1086
+ .file-viewer-code-grid {
1087
+ display: grid;
1088
+ grid-template-columns: max-content minmax(max-content, 1fr);
1089
+ width: max-content;
1090
+ min-width: 100%;
1091
+ min-height: 100%;
1092
+ }
1093
+ .file-viewer-line-numbers,
1094
+ .file-viewer-code {
1095
+ margin: 0;
1096
+ padding: 10px 12px;
1097
+ border: 0;
1098
+ border-radius: 0;
1099
+ background: var(--bg);
1100
+ color: var(--text);
1101
+ font:
1102
+ 14px/1.55 "SF Mono",
1103
+ "Fira Code",
1104
+ monospace;
1105
+ white-space: pre;
1106
+ }
1107
+ .file-viewer-line-numbers {
1108
+ position: sticky;
1109
+ left: 0;
1110
+ z-index: 1;
1111
+ padding-right: 8px;
1112
+ background: var(--surface);
1113
+ border-right: 1px solid var(--border);
1114
+ color: var(--text-dim);
1115
+ text-align: right;
1116
+ user-select: none;
1117
+ }
1118
+ .file-viewer-code {
1119
+ padding-left: 12px;
1120
+ }
1121
+ .file-viewer-code code,
1122
+ .file-viewer-code code.hljs {
1123
+ padding: 0;
1124
+ background: transparent;
1125
+ font: inherit;
1126
+ }
1127
+ .file-viewer-binary-meta {
1128
+ padding: 18px 14px 10px;
1129
+ color: var(--text-dim);
1130
+ overflow-wrap: anywhere;
1131
+ }
1132
+ .file-viewer-download {
1133
+ display: inline-flex;
1134
+ min-height: 44px;
1135
+ margin: 0 14px;
1136
+ padding: 8px 12px;
1137
+ border: 1px solid var(--border);
1138
+ border-radius: var(--radius);
1139
+ align-items: center;
1140
+ color: var(--accent);
1141
+ text-decoration: none;
1142
+ }
1143
+ .file-viewer-download:hover,
1144
+ .file-viewer-download:focus-visible {
1145
+ border-color: var(--accent);
1146
+ }
1147
+ @media (min-width: 1024px) {
1148
+ body.file-viewer-open {
1149
+ padding-right: min(55vw, 800px);
1150
+ }
1151
+ #file-viewer {
1152
+ left: auto;
1153
+ width: min(55vw, 800px);
1154
+ border-left: 1px solid var(--border);
1155
+ }
1156
+ }
1157
+
993
1158
  /* Image attach */
994
1159
  #attach-preview {
995
1160
  display: none;
@@ -0,0 +1,6 @@
1
+ export function agentKeyFromCommand(agentCmd) {
2
+ const key = agentCmd.trim().split(/\s+/, 1)[0];
3
+ if (!key)
4
+ throw new Error("ACP agent command is empty");
5
+ return key;
6
+ }
@@ -70,14 +70,51 @@ function looksLikeUtf8Text(buf) {
70
70
  if (byte === 0)
71
71
  return false;
72
72
  }
73
- try {
74
- const decoder = new TextDecoder("utf-8", { fatal: true });
75
- decoder.decode(buf);
76
- return true;
77
- }
78
- catch {
79
- return false;
73
+ return decodesAsUtf8ToleratingTailTruncation(buf);
74
+ }
75
+ /**
76
+ * Maximum bytes one UTF-8 code point can occupy. A TRAILING code point that
77
+ * was cut off by the sampling window contributes at most one byte less than
78
+ * a full character (a complete 4-byte character would decode), so backing up
79
+ * `MAX_UTF8_CHAR_BYTES - 1` bytes always reaches the preceding boundary.
80
+ */
81
+ const MAX_UTF8_CHAR_BYTES = 4;
82
+ /**
83
+ * Whether `buf` decodes cleanly as UTF-8, allowing its FINAL multi-byte
84
+ * character to be cut off at the buffer end.
85
+ *
86
+ * Why the tolerance: sniffMime only receives the first 4096 bytes of a
87
+ * larger file (the viewer's `SNIFF_BYTES` window in src/files/routes.ts and
88
+ * the upload-pipeline head). Content dense in multi-byte characters —
89
+ * Chinese, Japanese, emoji, ... — regularly leaves that window slicing
90
+ * clean through a character. A strict whole-buffer fatal decode then
91
+ * misclassifies perfectly valid UTF-8 text as binary, so the viewer
92
+ * force-downloads it instead of previewing it (real-world regression:
93
+ * ~100 of the markdown notes under ~/mine/space downloaded instead of
94
+ * rendering, even though every one of them was valid UTF-8).
95
+ *
96
+ * This relaxes ONLY the tail: a truncated trailing code point occupies at
97
+ * most 3 bytes of the buffer, so backing up at most 3 bytes reaches a
98
+ * character boundary — and the loop never trims the whole buffer, because an
99
+ * empty prefix would trivially "decode" and would turn short garbage buffers
100
+ * into text. NUL bytes and genuinely invalid sequences elsewhere fail every
101
+ * prefix attempt and still fall through to application/octet-stream — the
102
+ * "binary stays binary" security posture of the sniff is unchanged.
103
+ */
104
+ function decodesAsUtf8ToleratingTailTruncation(buf) {
105
+ for (let end = buf.length; end >= Math.max(1, buf.length - (MAX_UTF8_CHAR_BYTES - 1)); end--) {
106
+ try {
107
+ // Fresh decoder per attempt: reuse-after-throw is not part of the
108
+ // TextDecoder contract across implementations.
109
+ new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, end));
110
+ return true;
111
+ }
112
+ catch {
113
+ // Cut deeper into the tail. Only a trailing truncated character can
114
+ // make a later attempt succeed — real corruption fails all of them.
115
+ }
80
116
  }
117
+ return false;
81
118
  }
82
119
  /**
83
120
  * Mimes we are willing to render inline in <img>. Everything else is forced
@@ -1,5 +1,6 @@
1
- // All whitelisted paths must be GETs that read non-sensitive data, or static
2
- // assets needed before the user can present a token.
1
+ // All whitelisted paths must be GETs that either read non-sensitive data,
2
+ // serve static assets needed before the user can present a token, or enforce
3
+ // independent ticket/HMAC authentication inside their route handler.
3
4
  const WHITELIST = [
4
5
  // Public probes
5
6
  { method: "GET", test: (p) => p === "/api/v1/version" },
@@ -35,6 +36,12 @@ const WHITELIST = [
35
36
  method: "GET",
36
37
  test: (p) => /^\/api\/v1\/sessions\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9._-]+$/.test(p),
37
38
  },
39
+ // File viewer content — read-only bytes for one confirmed path,
40
+ // authenticated via HMAC sig+exp query string for the same reason as
41
+ // image GETs above (media tags / downloads cannot send Authorization
42
+ // headers). The content handler verifies the signature before serving:
43
+ // without a valid sig+exp it returns 401, never bytes.
44
+ { method: "GET", test: (p) => p === "/api/v1/files/content" },
38
45
  // --- Share viewer (public read-only snapshots) ---
39
46
  // Viewer HTML shell + image proxy + viewer-namespaced static assets
40
47
  // (CSS/JS) all live under /s/* — see src/share/routes.ts. The auth gate
package/lib/bridge.js CHANGED
@@ -3,6 +3,7 @@ import { Writable, Readable } from "node:stream";
3
3
  import { EventEmitter } from "node:events";
4
4
  import * as acp from "@agentclientprotocol/sdk";
5
5
  import { interruptBashProc } from "./session-manager.js";
6
+ import { abbreviateHomePath } from "./home-path.js";
6
7
  import { log } from "./log.js";
7
8
  const blog = log.scope("bridge");
8
9
  export class AgentBridge extends EventEmitter {
@@ -12,16 +13,27 @@ export class AgentBridge extends EventEmitter {
12
13
  permissionRequestSessions = new Map();
13
14
  silentSessions = new Set(); // Sessions that don't emit events
14
15
  silentBuffers = new Map(); // Text buffers for silent sessions
16
+ pendingNewSessions = 0;
17
+ unboundNewSessionIds = new Set();
18
+ pendingSessionUpdates = new Map();
15
19
  pendingAborts = new Map();
16
20
  deadReason = null;
17
21
  stderrTail = "";
18
22
  closedProcesses = new WeakSet();
19
23
  agentCmd;
24
+ sessionIds;
20
25
  reloading = false;
21
26
  attachmentDispatcher = null;
22
- constructor(agentCmd) {
27
+ constructor(agentCmd, sessionIds) {
23
28
  super();
24
29
  this.agentCmd = agentCmd;
30
+ this.sessionIds = sessionIds;
31
+ }
32
+ agentSessionId(webSessionId) {
33
+ const id = this.sessionIds.getAgentSessionId(webSessionId);
34
+ if (!id)
35
+ throw new Error(`Session is not available for the current agent: ${webSessionId}`);
36
+ return id;
25
37
  }
26
38
  /**
27
39
  * Inject the dispatcher used to translate client attachment refs into
@@ -99,29 +111,57 @@ export class AgentBridge extends EventEmitter {
99
111
  async newSession(cwd, opts) {
100
112
  if (!this.conn)
101
113
  throw new Error("Not connected");
102
- const session = await this.conn.newSession({
103
- cwd,
104
- mcpServers: [],
105
- });
106
- const configOptions = (session.configOptions ??
107
- []);
108
- if (!opts?.silent) {
109
- this.emit("event", {
110
- type: "session_created",
111
- sessionId: session.sessionId,
114
+ this.pendingNewSessions++;
115
+ try {
116
+ const session = await this.conn.newSession({
112
117
  cwd,
113
- configOptions,
118
+ mcpServers: [],
114
119
  });
120
+ if (opts?.silent) {
121
+ this.pendingSessionUpdates.delete(session.sessionId);
122
+ this.silentSessions.add(session.sessionId);
123
+ }
124
+ else {
125
+ this.unboundNewSessionIds.add(session.sessionId);
126
+ }
127
+ const configOptions = (session.configOptions ??
128
+ []);
129
+ return { sessionId: session.sessionId, configOptions };
115
130
  }
116
- return { sessionId: session.sessionId, configOptions };
131
+ finally {
132
+ this.pendingNewSessions--;
133
+ if (this.pendingNewSessions === 0) {
134
+ for (const sessionId of this.pendingSessionUpdates.keys()) {
135
+ if (!this.unboundNewSessionIds.has(sessionId)) {
136
+ this.pendingSessionUpdates.delete(sessionId);
137
+ blog.warn("discarded update for unrelated unmapped ACP session", {
138
+ sessionId,
139
+ });
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ sessionMapped(agentSessionId) {
146
+ this.unboundNewSessionIds.delete(agentSessionId);
147
+ const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
148
+ this.pendingSessionUpdates.delete(agentSessionId);
149
+ for (const update of updates) {
150
+ void this.handleSessionUpdate({ sessionId: agentSessionId, update });
151
+ }
152
+ }
153
+ discardUnboundSession(agentSessionId) {
154
+ this.unboundNewSessionIds.delete(agentSessionId);
155
+ this.pendingSessionUpdates.delete(agentSessionId);
117
156
  }
118
157
  async loadSession(sessionId, cwd) {
119
158
  if (!this.conn)
120
159
  throw new Error("Not connected");
160
+ const agentSessionId = this.agentSessionId(sessionId);
121
161
  let session;
122
162
  try {
123
163
  session = await this.conn.loadSession({
124
- sessionId,
164
+ sessionId: agentSessionId,
125
165
  cwd,
126
166
  mcpServers: [],
127
167
  });
@@ -144,6 +184,7 @@ export class AgentBridge extends EventEmitter {
144
184
  type: "session_created",
145
185
  sessionId,
146
186
  cwd,
187
+ cwdDisplay: abbreviateHomePath(cwd),
147
188
  configOptions,
148
189
  });
149
190
  return { sessionId, configOptions };
@@ -152,7 +193,19 @@ export class AgentBridge extends EventEmitter {
152
193
  if (!this.conn)
153
194
  throw new Error("Not connected");
154
195
  const result = await this.conn.setSessionConfigOption({
155
- sessionId,
196
+ sessionId: this.agentSessionId(sessionId),
197
+ configId,
198
+ ...(typeof value === "boolean"
199
+ ? { type: "boolean", value }
200
+ : { value }),
201
+ });
202
+ return result.configOptions;
203
+ }
204
+ async setAgentConfigOption(agentSessionId, configId, value) {
205
+ if (!this.conn)
206
+ throw new Error("Not connected");
207
+ const result = await this.conn.setSessionConfigOption({
208
+ sessionId: agentSessionId,
156
209
  configId,
157
210
  ...(typeof value === "boolean"
158
211
  ? { type: "boolean", value }
@@ -196,7 +249,7 @@ export class AgentBridge extends EventEmitter {
196
249
  promptParts.push({ type: "text", text });
197
250
  const result = (await Promise.race([
198
251
  this.conn.prompt({
199
- sessionId,
252
+ sessionId: this.agentSessionId(sessionId),
200
253
  prompt: promptParts,
201
254
  }),
202
255
  abortPromise,
@@ -241,7 +294,10 @@ export class AgentBridge extends EventEmitter {
241
294
  this.denyPermission(requestId);
242
295
  }
243
296
  }
244
- await this.conn?.cancel({ sessionId });
297
+ await this.conn?.cancel({ sessionId: this.agentSessionId(sessionId) });
298
+ }
299
+ async cancelAgentSession(agentSessionId) {
300
+ await this.conn?.cancel({ sessionId: agentSessionId });
245
301
  }
246
302
  /**
247
303
  * Mark the agent subprocess as dead. Rejects in-flight prompts and emits
@@ -374,6 +430,8 @@ export class AgentBridge extends EventEmitter {
374
430
  // 4. Clean up bridge-side silent session state
375
431
  this.silentSessions.clear();
376
432
  this.silentBuffers.clear();
433
+ this.unboundNewSessionIds.clear();
434
+ this.pendingSessionUpdates.clear();
377
435
  // 5. Invalidate title service session
378
436
  titleService.invalidate();
379
437
  // 5. Clear liveSessions so ensureResumed() will re-register on next access
@@ -457,6 +515,10 @@ export class AgentBridge extends EventEmitter {
457
515
  }
458
516
  // --- ACP Client callbacks ---
459
517
  handlePermission(params) {
518
+ const webSessionId = this.sessionIds.getWebSessionId(params.sessionId);
519
+ if (!webSessionId) {
520
+ return Promise.resolve({ outcome: { outcome: "cancelled" } });
521
+ }
460
522
  const requestId = crypto.randomUUID();
461
523
  const toolCall = params.toolCall;
462
524
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- toolCall may be undefined in practice
@@ -467,11 +529,11 @@ export class AgentBridge extends EventEmitter {
467
529
  return new Promise((resolve) => {
468
530
  // Register resolver BEFORE emitting, so synchronous auto-approve can find it
469
531
  this.permissionResolvers.set(requestId, resolve);
470
- this.permissionRequestSessions.set(requestId, params.sessionId);
532
+ this.permissionRequestSessions.set(requestId, webSessionId);
471
533
  this.emit("event", {
472
534
  type: "permission_request",
473
535
  requestId,
474
- sessionId: params.sessionId,
536
+ sessionId: webSessionId,
475
537
  title,
476
538
  toolCallId,
477
539
  options: params.options,
@@ -484,9 +546,23 @@ export class AgentBridge extends EventEmitter {
484
546
  }
485
547
  handleSessionUpdate(params) {
486
548
  const update = params.update;
487
- const sessionId = params.sessionId;
488
- if (this.silentSessions.has(sessionId)) {
489
- this.captureSilentText(sessionId, update);
549
+ const agentSessionId = params.sessionId;
550
+ if (this.silentSessions.has(agentSessionId)) {
551
+ this.captureSilentText(agentSessionId, update);
552
+ return Promise.resolve();
553
+ }
554
+ const sessionId = this.sessionIds.getWebSessionId(agentSessionId);
555
+ if (!sessionId) {
556
+ if (this.pendingNewSessions > 0 ||
557
+ this.unboundNewSessionIds.has(agentSessionId)) {
558
+ const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
559
+ updates.push(update);
560
+ this.pendingSessionUpdates.set(agentSessionId, updates);
561
+ return Promise.resolve();
562
+ }
563
+ blog.warn("ignored event for unmapped ACP session", {
564
+ sessionId: agentSessionId,
565
+ });
490
566
  return Promise.resolve();
491
567
  }
492
568
  const event = this.sessionUpdateToEvent(sessionId, update);
@@ -528,9 +604,26 @@ export class AgentBridge extends EventEmitter {
528
604
  id: update.toolCallId,
529
605
  status: update.status ?? "",
530
606
  content: (update.content ?? undefined),
607
+ ...(typeof update.title === "string" ? { title: update.title } : {}),
608
+ ...(typeof update.kind === "string" ? { kind: update.kind } : {}),
609
+ ...(update.rawInput ? { rawInput: update.rawInput } : {}),
610
+ ...(Object.hasOwn(update, "rawOutput")
611
+ ? { rawOutput: update.rawOutput }
612
+ : {}),
613
+ ...(Array.isArray(update.locations)
614
+ ? { locations: update.locations }
615
+ : {}),
531
616
  };
532
617
  case "plan":
533
618
  return { type: "plan", sessionId, entries: update.entries };
619
+ case "usage_update":
620
+ return {
621
+ type: "usage_update",
622
+ sessionId,
623
+ used: update.used,
624
+ size: update.size,
625
+ cost: update.cost,
626
+ };
534
627
  case "config_option_update":
535
628
  return {
536
629
  type: "config_option_update",
@@ -40,6 +40,17 @@ function handleToolCall(event, sessions, store) {
40
40
  rawInput: event.rawInput,
41
41
  }, { from_ref: "agent" });
42
42
  }
43
+ function handleUsageUpdate(event, sessions) {
44
+ sessions.state.patch(event.sessionId, {
45
+ runtime: {
46
+ contextUsage: {
47
+ used: event.used,
48
+ size: event.size,
49
+ ...(event.cost !== undefined ? { cost: event.cost } : {}),
50
+ },
51
+ },
52
+ });
53
+ }
43
54
  function handlePlan(event, sessions, store) {
44
55
  sessions.flushBuffers(event.sessionId);
45
56
  sessions.state.patch(event.sessionId, {
@@ -177,7 +188,7 @@ function handlePromptDone(event, sessions, store) {
177
188
  ...(event.promptId ? { promptId: event.promptId } : {}),
178
189
  }, { from_ref: "agent" });
179
190
  }
180
- function handleError(event, sessions) {
191
+ function handleError(event, sessions, store) {
181
192
  if (event.sessionId) {
182
193
  // Same attribution as a completion: a superseded turn failing late must
183
194
  // not end the turn that replaced it. The buffered tail still flushes.
@@ -196,6 +207,10 @@ function handleError(event, sessions) {
196
207
  sessions.state.patch(event.sessionId, {
197
208
  runtime: { streaming: { assistant: false, thinking: false } },
198
209
  });
210
+ store.saveEvent(event.sessionId, event.type, {
211
+ message: event.message,
212
+ ...(event.promptId ? { promptId: event.promptId } : {}),
213
+ }, { from_ref: "agent" });
199
214
  }
200
215
  }
201
216
  function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager) {
@@ -218,7 +233,15 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
218
233
  handleToolCall(event, sessions, store);
219
234
  return false;
220
235
  case "tool_call_update":
221
- store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content }, { from_ref: "agent" });
236
+ store.saveEvent(event.sessionId, event.type, {
237
+ id: event.id,
238
+ status: event.status,
239
+ content: event.content,
240
+ title: event.title,
241
+ kind: event.kind,
242
+ rawInput: event.rawInput,
243
+ locations: event.locations,
244
+ }, { from_ref: "agent" });
222
245
  return false;
223
246
  case "plan":
224
247
  handlePlan(event, sessions, store);
@@ -229,7 +252,7 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
229
252
  handlePromptDone(event, sessions, store);
230
253
  return false;
231
254
  case "error":
232
- handleError(event, sessions);
255
+ handleError(event, sessions, store);
233
256
  return false;
234
257
  }
235
258
  return false;
@@ -253,6 +276,10 @@ function maybePushNotify(event, pushService) {
253
276
  });
254
277
  }
255
278
  export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService, _clientRegistry) {
279
+ if (event.type === "usage_update") {
280
+ handleUsageUpdate(event, sessions);
281
+ return;
282
+ }
256
283
  if (event.type === "available_commands_update") {
257
284
  const snapshot = sessions.updateAgentCommands(event.sessionId, event.commands);
258
285
  if (sessions.restoringSessions.has(event.sessionId))
@@ -266,6 +293,7 @@ export function handleAgentEvent(event, sessions, store, bridge, config, sseMana
266
293
  }
267
294
  sessions.state.clearStreaming();
268
295
  sessions.state.clearPlans();
296
+ sessions.state.clearContextUsage();
269
297
  for (const snapshot of sessions.clearAgentCommands()) {
270
298
  sseManager.broadcast({
271
299
  type: "available_commands_update",