beeos-claw 0.5.16 → 0.5.17-staging.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.
package/README.md CHANGED
@@ -152,6 +152,24 @@ Configuration is resolved from three sources in priority order:
152
152
 
153
153
  Connects local OpenClaw agents to the BeeOS platform via the Agent Communication Protocol. Handles prompt routing, streaming responses, session history replay, and tool call forwarding.
154
154
 
155
+ ### Streaming reply boundary (ACP → Message Service Envelope v3)
156
+
157
+ beeos-claw is **only the ACP→v3 protocol translator** for outbound agent replies. All Message Service (MS) Envelope v3 streaming machinery — PATCH batching, snapshot semantics, lazy `MessageClient` connect, open-failure `sendV3` fallback, `stopReason` whitelisting — lives in `@beeos-ai/message-sdk` (`MessageStream` + `MessageClient`).
158
+
159
+ `src/message-reply-streamer.ts` keeps a `Map<sessionKey, MessageStream>` and per-incoming-update routes:
160
+
161
+ | ACP `sessionUpdate` | SDK call |
162
+ |---|---|
163
+ | `agent_message_chunk` | `stream.appendBody(text)` |
164
+ | `agent_thought_chunk` | `stream.appendThinking(text)` |
165
+ | `tool_call` | `stream.appendToolUse(id, name, args, "streaming")` |
166
+ | `tool_call_update` (completed / failed) | `stream.appendToolResult(id, content, isError)` |
167
+ | `tool_call_update` content blocks (`resource_link` / `file`) | `stream.addPart({type: "file", ...})` |
168
+
169
+ The only beeos-claw-side concern is the `routeUpdateIntoStream` + extractor helpers at the bottom of `message-reply-streamer.ts`. Anything tied to MS — connect lifecycle, transient PATCH retries, terminal-write fallback when the open POST failed, MS `ValidateStopReason` whitelist — is the SDK's responsibility. Other agent runtimes (device-agent, beeos-claude, …) can drop the same SDK-level wiring in and only need to ship their own ACP-equivalent translator.
170
+
171
+ See [`@beeos-ai/message-sdk` README](../../sdks/message-sdk-node/README.md#streaming-agent-replies--envelope-v3) for the SDK-side contract.
172
+
155
173
  ### File Transfer
156
174
 
157
175
  Provides the `beeos_upload_file` tool for agents to upload files from the sandbox to BeeOS platform storage. Also supports file staging via the `POST /beeos/files/stage` HTTP endpoint for downloading files from the platform into the agent sandbox.
package/dist/a2a/tools.js CHANGED
@@ -1 +1 @@
1
- import{agentFetch}from"../agent-auth.js";import{resolveAgentIdFromMentions}from"./mention-resolver.js";async function a2aFetch(t,e,a){const s=`${t.agentGatewayUrl}${e}`;return t.agentKeys?agentFetch(s,t.agentKeys,a):fetch(s,a)}export{a2aFetch};function normalizeState(t){if(!t)return"";const e=t.toLowerCase();return{task_state_submitted:"submitted",task_state_delivered:"submitted",task_state_working:"working",task_state_completed:"completed",task_state_failed:"failed",task_state_timeout:"timeout",task_state_canceled:"canceled",task_state_rejected:"rejected",task_state_input_required:"input-required",task_state_auth_required:"auth-required"}[e]??e}function unwrapTask(t){if(!t||"object"!=typeof t)return;const e=t,a=e.task;return a&&"object"==typeof a?a:e}function extractArtifactText(t){const e=t?.artifacts;if(!e)return;return e.flatMap(t=>t.parts??[]).filter(t=>"string"==typeof t.text&&(void 0===t.kind||"text"===t.kind)).map(t=>t.text).join("\n")||void 0}function extractStatusText(t){const e=t?.status,a=e?.message?.parts,s=a?.[0]?.text;return"string"==typeof s?s:void 0}function taskState(t){return normalizeState(t?.status?.state)}const TERMINAL_FAILURE_STATES=new Set(["failed","timeout","canceled","rejected"]);function taskContextId(t){const e=t?.contextId;return"string"==typeof e&&""!==e?e:void 0}function buildTaskPayload(t,e,a){const s=taskState(t),n=extractArtifactText(t),r=extractStatusText(t),i=taskContextId(t),o={status:s,is_terminal:"completed"===s||TERMINAL_FAILURE_STATES.has(s)};return e&&(o.task_id=e),a&&(o.agent_id=a),i&&(o.context_id=i),n?o.result=n:r&&(o.error=r),o}export{unwrapTask,extractArtifactText,extractStatusText,taskState,taskContextId,normalizeState,buildTaskPayload};export class TaskHandleRegistry{seq=0;handles=new Map;taskIdToHandle=new Map;create(t,e,a,s){const n="bth_"+ ++this.seq;return this.handles.set(n,{taskId:t,agentId:e,contextId:a,sessionKey:s}),this.taskIdToHandle.set(t,n),n}resolveTaskId(t){const e=this.handles.get(t);return e?e.taskId:t}resolveAgentId(t){const e=this.handles.get(t);if(e)return e.agentId;const a=this.taskIdToHandle.get(t);return a?this.handles.get(a)?.agentId:void 0}}const defaultRegistry=new TaskHandleRegistry;export function registerA2ATools(t,e,a=defaultRegistry,s,n,r,i){t.registerTool({name:"beeos_discover_agents",description:"List agents available for A2A collaboration. When called without a query, returns all visible agents (your own agents + public agents). When called with a query, filters by name, description, or skill. Results include an `isSelf` flag marking the agent that represents the current instance (do not call yourself). Each result includes `instanceName` (human-readable device/instance label) when available.",parameters:{type:"object",properties:{query:{type:"string",description:"Optional keyword to filter agents by name, description, or skill. Omit to list all agents."}},required:[]},async execute(t,a,s){const{query:n}=a??{};try{const t=new URLSearchParams({limit:"50"});n&&t.set("query",n);const a=await a2aFetch(e,`/api/v1/a2a/discover?${t}`,{signal:s});if(!a.ok)return{content:JSON.stringify({error:`discover failed: ${a.status}`})};const r=await a.json(),o=i?.();if(o&&Array.isArray(r?.data))for(const t of r.data)t.isSelf=t.instance_id===o;return{content:JSON.stringify(r,null,2)}}catch(t){if(s?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t)})}}}}),t.registerTool({name:"beeos_call_agent",description:"Ask another agent to perform a task. This creates the task ASYNCHRONOUSLY and returns immediately with a handle ({ task_id, agent_id, context_id, status }) — it does NOT wait for the result. To fetch the agent's reply, call beeos_get_task with the returned task_id (and agent_id). Do NOT call beeos_call_agent again to poll an existing task — that creates a brand-new task.",parameters:{type:"object",properties:{agentId:{type:"string",description:"The target agent UUID (e.g. d04080d1-...) or display name. Prefer the UUID from discover results or @mention metadata when available."},message:{type:"string",description:"The task description or message to send to the agent"},taskId:{type:"string",description:"Optional existing task ID to continue a multi-turn conversation. When provided, the message is appended to the existing task instead of creating a new one."},files:{type:"array",items:{type:"string"},description:"Optional file URLs to include with the request"},contextId:{type:"string",description:"Optional context ID for multi-turn conversations with the same agent"},timeoutMs:{type:"number",description:"Server-side task timeout in milliseconds (how long the target agent may run before the task is timed out). Defaults to 600000 (10 minutes). This is NOT a client wait time — beeos_call_agent returns immediately regardless."}},required:["agentId","message"]},async execute(t,i,o){let{agentId:d,message:c,files:l,contextId:u,timeoutMs:g,taskId:h}=i;const f=s?.(t),p=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;if(!p.test(d)){const a=n?.(t);if(a?.length){const t=resolveAgentIdFromMentions(a,d);t&&(e.logger.info?.(`beeos_call_agent: resolved "${d}" → ${t} via mention context`),d=t)}if(!p.test(d))try{const t=await a2aFetch(e,`/api/v1/a2a/discover?query=${encodeURIComponent(d)}&limit=10`,{signal:o});if(t.ok){const a=await t.json(),s=a?.data??[],n=d.toLowerCase(),r=s.find(t=>t.name.toLowerCase()===n)??s.find(t=>t.name.toLowerCase().includes(n));if(!r)return{content:JSON.stringify({error:`Could not resolve agent name "${d}". Available agents: ${s.map(t=>`${t.name} (${t.id})`).join(", ")||"none"}`})};e.logger.info?.(`beeos_call_agent: resolved "${d}" → ${r.id} (${r.name}) via discover`),d=r.id}}catch{}}const k=u||(f?`session:${f}`:""),m=g||6e5;try{const t={jsonrpc:"2.0",id:`claw-${Date.now()}`,method:"SendMessage",params:{message:{role:"user",parts:[{text:c}],...k?{contextId:k}:{},...h?{taskId:h}:{}},configuration:{timeoutMs:m}}},s=`/api/v1/a2a/${encodeURIComponent(d)}`,n=AbortSignal.timeout(3e4),i=o?AbortSignal.any([o,n]):n,l=await a2aFetch(e,s,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),signal:i}),g=await l.text();let p;try{p=JSON.parse(g)}catch{p={}}if(!l.ok||p.error){if(401===l.status||403===l.status){const t=p.error??{};-32600===t.code&&r&&r("_beeos/auth_required",{type:"auth_required",target_agent_id:d,target_agent_name:d,title:"Authentication Required",content:`Agent **${d}** requires authentication.`})}const t=p.error?JSON.stringify(p.error):g;return{content:JSON.stringify({error:`send task failed: ${l.status} ${t}`,agent_id:d})}}const _=unwrapTask(p.result),y=_?.id;return y?(a.create(y,d,u,f),{content:JSON.stringify({status:taskState(_)||"working",task_id:y,agent_id:d,context_id:taskContextId(_)??k??void 0,message:`Task created. Call beeos_get_task with task_id "${y}" (and agent_id "${d}") to fetch status and result.`})}):{content:JSON.stringify({error:"task created but server returned no task_id",agent_id:d})}}catch(t){if(o?.aborted)throw t;return t instanceof DOMException&&"TimeoutError"===t.name?{content:JSON.stringify({error:"create task request timed out after 30000ms",agent_id:d})}:{content:JSON.stringify({error:t instanceof Error?t.message:String(t),agent_id:d})}}}}),t.registerTool({name:"beeos_get_task",description:'Fetch the current state AND data of an existing A2A task by its task_id. Use this to check on a task that beeos_call_agent created (it returns status "working" with a task_id), or to re-read a finished task. Always returns a JSON object { status, task_id, agent_id, context_id, is_terminal, result?, error? }: `result` holds the produced output (present whenever the device produced output, EVEN for timeout/canceled/failed tasks that still produced content); `error` holds the failure reason when there is no produced output; `is_terminal` indicates whether the task has reached a final state. Do NOT call beeos_call_agent again to re-query an existing task — that creates a brand-new task.',parameters:{type:"object",properties:{taskId:{type:"string",description:"The task ID returned by a previous beeos_call_agent call."},agentId:{type:"string",description:"The target agent UUID that owns the task (the same agentId passed to beeos_call_agent). Optional when the task was created earlier in this session — it is resolved automatically."}},required:["taskId"]},async execute(t,s,n){const{taskId:r,agentId:i}=s??{};if(!r)return{content:JSON.stringify({error:"taskId is required"})};const o=i||a.resolveAgentId(r);if(!o)return{content:JSON.stringify({error:`agentId is required to query task "${r}" (it was not created in this session, so the target agent is unknown). Pass the agentId you originally called.`,task_id:r})};try{const t=`/api/v1/a2a/${encodeURIComponent(o)}`,a=await a2aFetch(e,t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:`claw-get-${Date.now()}`,method:"GetTask",params:{id:r}}),signal:n}),s=await a.text();let i;try{i=JSON.parse(s)}catch{i={}}if(!a.ok||i.error){const t=i.error?JSON.stringify(i.error):s;return{content:JSON.stringify({error:`get task failed: ${a.status} ${t}`,task_id:r,agent_id:o})}}const d=unwrapTask(i.result);return{content:JSON.stringify(buildTaskPayload(d,r,o))}}catch(t){if(n?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t),task_id:r,agent_id:o})}}}})}
1
+ import{agentFetch}from"../agent-auth.js";import{resolveAgentIdFromMentions}from"../delegation/mention-resolver.js";async function a2aFetch(t,e,a){const s=`${t.agentGatewayUrl}${e}`;return t.agentKeys?agentFetch(s,t.agentKeys,a):fetch(s,a)}export{a2aFetch};function normalizeState(t){if(!t)return"";const e=t.toLowerCase();return{task_state_submitted:"submitted",task_state_delivered:"submitted",task_state_working:"working",task_state_completed:"completed",task_state_failed:"failed",task_state_timeout:"timeout",task_state_canceled:"canceled",task_state_rejected:"rejected",task_state_input_required:"input-required",task_state_auth_required:"auth-required"}[e]??e}function unwrapTask(t){if(!t||"object"!=typeof t)return;const e=t,a=e.task;return a&&"object"==typeof a?a:e}function extractArtifactText(t){const e=t?.artifacts;if(!e)return;return e.flatMap(t=>t.parts??[]).filter(t=>"string"==typeof t.text&&(void 0===t.kind||"text"===t.kind)).map(t=>t.text).join("\n")||void 0}function extractStatusText(t){const e=t?.status,a=e?.message?.parts,s=a?.[0]?.text;return"string"==typeof s?s:void 0}function taskState(t){return normalizeState(t?.status?.state)}const TERMINAL_FAILURE_STATES=new Set(["failed","timeout","canceled","rejected"]);function taskContextId(t){const e=t?.contextId;return"string"==typeof e&&""!==e?e:void 0}function buildTaskPayload(t,e,a){const s=taskState(t),n=extractArtifactText(t),r=extractStatusText(t),i=taskContextId(t),o={status:s,is_terminal:"completed"===s||TERMINAL_FAILURE_STATES.has(s)};return e&&(o.task_id=e),a&&(o.agent_id=a),i&&(o.context_id=i),n?o.result=n:r&&(o.error=r),o}export{unwrapTask,extractArtifactText,extractStatusText,taskState,taskContextId,normalizeState,buildTaskPayload};export class TaskHandleRegistry{seq=0;handles=new Map;taskIdToHandle=new Map;create(t,e,a,s){const n="bth_"+ ++this.seq;return this.handles.set(n,{taskId:t,agentId:e,contextId:a,sessionKey:s}),this.taskIdToHandle.set(t,n),n}resolveTaskId(t){const e=this.handles.get(t);return e?e.taskId:t}resolveAgentId(t){const e=this.handles.get(t);if(e)return e.agentId;const a=this.taskIdToHandle.get(t);return a?this.handles.get(a)?.agentId:void 0}}const defaultRegistry=new TaskHandleRegistry;export function registerA2ATools(t,e,a=defaultRegistry,s,n,r,i){t.registerTool({name:"beeos_discover_agents",description:"List agents available for A2A collaboration. When called without a query, returns all visible agents (your own agents + public agents). When called with a query, filters by name, description, or skill. Results include an `isSelf` flag marking the agent that represents the current instance (do not call yourself). Each result includes `instanceName` (human-readable device/instance label) when available.",parameters:{type:"object",properties:{query:{type:"string",description:"Optional keyword to filter agents by name, description, or skill. Omit to list all agents."}},required:[]},async execute(t,a,s){const{query:n}=a??{};try{const t=new URLSearchParams({limit:"50"});n&&t.set("query",n);const a=await a2aFetch(e,`/api/v1/a2a/discover?${t}`,{signal:s});if(!a.ok)return{content:JSON.stringify({error:`discover failed: ${a.status}`})};const r=await a.json(),o=i?.();if(o&&Array.isArray(r?.data))for(const t of r.data)t.isSelf=t.instance_id===o;return{content:JSON.stringify(r,null,2)}}catch(t){if(s?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t)})}}}}),t.registerTool({name:"beeos_call_agent",description:"Ask another agent to perform a task. This creates the task ASYNCHRONOUSLY and returns immediately with a handle ({ task_id, agent_id, context_id, status }) — it does NOT wait for the result. To fetch the agent's reply, call beeos_get_task with the returned task_id (and agent_id). Do NOT call beeos_call_agent again to poll an existing task — that creates a brand-new task.",parameters:{type:"object",properties:{agentId:{type:"string",description:"The target agent UUID (e.g. d04080d1-...) or display name. Prefer the UUID from discover results or @mention metadata when available."},message:{type:"string",description:"The task description or message to send to the agent"},taskId:{type:"string",description:"Optional existing task ID to continue a multi-turn conversation. When provided, the message is appended to the existing task instead of creating a new one."},files:{type:"array",items:{type:"string"},description:"Optional file URLs to include with the request"},contextId:{type:"string",description:"Optional context ID for multi-turn conversations with the same agent"},timeoutMs:{type:"number",description:"Server-side task timeout in milliseconds (how long the target agent may run before the task is timed out). Defaults to 600000 (10 minutes). This is NOT a client wait time — beeos_call_agent returns immediately regardless."}},required:["agentId","message"]},async execute(t,i,o){let{agentId:d,message:c,files:l,contextId:u,timeoutMs:g,taskId:h}=i;const f=s?.(t),p=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;if(!p.test(d)){const a=n?.(t);if(a?.length){const t=resolveAgentIdFromMentions(a,d);t&&(e.logger.info?.(`beeos_call_agent: resolved "${d}" → ${t} via mention context`),d=t)}if(!p.test(d))try{const t=await a2aFetch(e,`/api/v1/a2a/discover?query=${encodeURIComponent(d)}&limit=10`,{signal:o});if(t.ok){const a=await t.json(),s=a?.data??[],n=d.toLowerCase(),r=s.find(t=>t.name.toLowerCase()===n)??s.find(t=>t.name.toLowerCase().includes(n));if(!r)return{content:JSON.stringify({error:`Could not resolve agent name "${d}". Available agents: ${s.map(t=>`${t.name} (${t.id})`).join(", ")||"none"}`})};e.logger.info?.(`beeos_call_agent: resolved "${d}" → ${r.id} (${r.name}) via discover`),d=r.id}}catch{}}const k=u||(f?`session:${f}`:""),m=g||6e5;try{const t={jsonrpc:"2.0",id:`claw-${Date.now()}`,method:"SendMessage",params:{message:{role:"user",parts:[{text:c}],...k?{contextId:k}:{},...h?{taskId:h}:{}},configuration:{timeoutMs:m}}},s=`/api/v1/a2a/${encodeURIComponent(d)}`,n=AbortSignal.timeout(3e4),i=o?AbortSignal.any([o,n]):n,l=await a2aFetch(e,s,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),signal:i}),g=await l.text();let p;try{p=JSON.parse(g)}catch{p={}}if(!l.ok||p.error){if(401===l.status||403===l.status){const t=p.error??{};-32600===t.code&&r&&r("_beeos/auth_required",{type:"auth_required",target_agent_id:d,target_agent_name:d,title:"Authentication Required",content:`Agent **${d}** requires authentication.`})}const t=p.error?JSON.stringify(p.error):g;return{content:JSON.stringify({error:`send task failed: ${l.status} ${t}`,agent_id:d})}}const _=unwrapTask(p.result),y=_?.id;return y?(a.create(y,d,u,f),{content:JSON.stringify({status:taskState(_)||"working",task_id:y,agent_id:d,context_id:taskContextId(_)??k??void 0,message:`Task created. Call beeos_get_task with task_id "${y}" (and agent_id "${d}") to fetch status and result.`})}):{content:JSON.stringify({error:"task created but server returned no task_id",agent_id:d})}}catch(t){if(o?.aborted)throw t;return t instanceof DOMException&&"TimeoutError"===t.name?{content:JSON.stringify({error:"create task request timed out after 30000ms",agent_id:d})}:{content:JSON.stringify({error:t instanceof Error?t.message:String(t),agent_id:d})}}}}),t.registerTool({name:"beeos_get_task",description:'Fetch the current state AND data of an existing A2A task by its task_id. Use this to check on a task that beeos_call_agent created (it returns status "working" with a task_id), or to re-read a finished task. Always returns a JSON object { status, task_id, agent_id, context_id, is_terminal, result?, error? }: `result` holds the produced output (present whenever the device produced output, EVEN for timeout/canceled/failed tasks that still produced content); `error` holds the failure reason when there is no produced output; `is_terminal` indicates whether the task has reached a final state. Do NOT call beeos_call_agent again to re-query an existing task — that creates a brand-new task.',parameters:{type:"object",properties:{taskId:{type:"string",description:"The task ID returned by a previous beeos_call_agent call."},agentId:{type:"string",description:"The target agent UUID that owns the task (the same agentId passed to beeos_call_agent). Optional when the task was created earlier in this session — it is resolved automatically."}},required:["taskId"]},async execute(t,s,n){const{taskId:r,agentId:i}=s??{};if(!r)return{content:JSON.stringify({error:"taskId is required"})};const o=i||a.resolveAgentId(r);if(!o)return{content:JSON.stringify({error:`agentId is required to query task "${r}" (it was not created in this session, so the target agent is unknown). Pass the agentId you originally called.`,task_id:r})};try{const t=`/api/v1/a2a/${encodeURIComponent(o)}`,a=await a2aFetch(e,t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:`claw-get-${Date.now()}`,method:"GetTask",params:{id:r}}),signal:n}),s=await a.text();let i;try{i=JSON.parse(s)}catch{i={}}if(!a.ok||i.error){const t=i.error?JSON.stringify(i.error):s;return{content:JSON.stringify({error:`get task failed: ${a.status} ${t}`,task_id:r,agent_id:o})}}const d=unwrapTask(i.result);return{content:JSON.stringify(buildTaskPayload(d,r,o))}}catch(t){if(n?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t),task_id:r,agent_id:o})}}}})}
@@ -0,0 +1 @@
1
+ import{describe,it,expect,vi}from"vitest";import{AcpGatewayBridgeCore,encodeBeeosChannelTag,stripBeeosChannelTag,decodeBeeosChannelId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";const TAG_RE_GLOBAL=/<!-- beeos:channel=(.+?) -->/g;function countTags(e){return e?[...e.matchAll(TAG_RE_GLOBAL)].length:0}function makeBridge(e){const t={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new AcpGatewaySessionState(t),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o={isConnected:!0,agentCancel:vi.fn(),request:vi.fn().mockImplementation(async t=>{const n=e?.gatewayResponses?.[t];return void 0!==n?n:{id:"job_test_123"}})},s=vi.fn().mockResolvedValue({ok:!0,json:async()=>({id:e?.allocChannelId??"chan_new"})});return{bridge:new AcpGatewayBridgeCore({state:n,transport:a,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:t,writeObs:vi.fn(),getGateway:()=>o,instanceId:"test-instance",thirdPartyChannels:new Set,agentFetch:s,agentGatewayUrl:"http://agent-gateway.test"}),state:n,transport:a,gateway:o,logger:t,agentFetch:s}}function cronUpdateRequest(e,t,n){return{jsonrpc:"2.0",id:e,method:"cron/update",params:{id:t,patch:n}}}function forwardedPatch(e){const t=e.request.mock.calls.find(e=>"cron.update"===e[0]);return expect(t,"expected a cron.update forward").toBeDefined(),t[1].patch}describe("encodeBeeosChannelTag / stripBeeosChannelTag idempotency",()=>{it("re-encoding a description that already carries a tag yields exactly one tag",()=>{const e=encodeBeeosChannelTag("user text","chan_a"),t=encodeBeeosChannelTag(e,"chan_b");expect(countTags(t)).toBe(1),expect(decodeBeeosChannelId(t)).toBe("chan_b"),expect(t.startsWith("user text")).toBe(!0)}),it("strip removes ALL tags (heals legacy double-tag descriptions)",()=>{const e="text\n\x3c!-- beeos:channel=old --\x3e\n\x3c!-- beeos:channel=new --\x3e";expect(stripBeeosChannelTag(e)).toBe("text"),expect(countTags(encodeBeeosChannelTag(e,"chan_c"))).toBe(1)})}),describe("handleCronRequest — cron.update channel reuse",()=>{it("cache hit: reuses the cached channel, no allocation, description untouched",async()=>{const e=makeBridge();e.bridge.cronChannelCache.set("job_test_123","chan_cached"),await e.bridge.handleRequest(cronUpdateRequest(1,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway);expect(t).not.toHaveProperty("description"),expect(t.delivery).toEqual({mode:"none"}),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_cached")}),it("cache miss + cron.list has a tagged description: reuses that channel, no allocation",async()=>{const e=makeBridge({gatewayResponses:{"cron.list":{jobs:[{id:"job_test_123",description:"user text\n\x3c!-- beeos:channel=chan_existing --\x3e"}]}}});await e.bridge.handleRequest(cronUpdateRequest(2,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway);expect(t).not.toHaveProperty("description"),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_existing")}),it("patch echoes the old tag: reuses it and keeps exactly one tag (idempotent encode)",async()=>{const e=makeBridge();await e.bridge.handleRequest(cronUpdateRequest(3,"job_test_123",{name:"renamed",description:"new text\n\x3c!-- beeos:channel=chan_old --\x3e",_beeos_internal:!0})),expect(e.agentFetch).not.toHaveBeenCalled();const t=forwardedPatch(e.gateway).description;expect(countTags(t)).toBe(1),expect(decodeBeeosChannelId(t)).toBe("chan_old"),expect(t.startsWith("new text")).toBe(!0),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_old")}),it("all sources miss: allocates once and persists the tag on the job's stored description",async()=>{const e=makeBridge({allocChannelId:"chan_new",gatewayResponses:{"cron.list":{jobs:[{id:"job_test_123",description:"keep me"}]}}});await e.bridge.handleRequest(cronUpdateRequest(4,"job_test_123",{name:"renamed",_beeos_internal:!0})),expect(e.agentFetch).toHaveBeenCalledTimes(1);const t=forwardedPatch(e.gateway);expect(t.description).toBe("keep me\n\x3c!-- beeos:channel=chan_new --\x3e"),expect(e.bridge.cronChannelCache.get("job_test_123")).toBe("chan_new")}),it("cron.add still always allocates a fresh channel (unchanged behavior)",async()=>{const e=makeBridge({allocChannelId:"chan_add"});await e.bridge.handleRequest({jsonrpc:"2.0",id:5,method:"cron/add",params:{name:"new-job",schedule:{kind:"cron",expression:"0 9 * * *"},sessionTarget:"isolated",payload:{kind:"agentTurn",message:"hi"},_beeos_internal:!0}}),expect(e.agentFetch).toHaveBeenCalledTimes(1);const t=e.gateway.request.mock.calls.find(e=>"cron.add"===e[0])[1];expect(decodeBeeosChannelId(t.description)).toBe("chan_add")})}),describe("handleCronRequest — cron.remove cache cleanup",()=>{it("drops the jobId → channelId mapping so late finished events cannot publish",async()=>{const e=makeBridge();e.bridge.cronChannelCache.set("job_test_123","chan_gone"),await e.bridge.handleRequest({jsonrpc:"2.0",id:6,method:"cron/remove",params:{id:"job_test_123"}}),expect(e.transport.sendAcpError).not.toHaveBeenCalled(),expect(e.bridge.cronChannelCache.has("job_test_123")).toBe(!1)})});
@@ -0,0 +1 @@
1
+ import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},n=new AcpGatewaySessionState(e),t={sendResult:vi.fn(),sendError:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},r={isConnected:!0,agentCancel:vi.fn(),request:vi.fn().mockResolvedValue({})};return{bridge:new AcpGatewayBridgeCore({state:n,transport:t,config:{gateway:{agentId:"main"},cronMaxDurationDefaultMs:3e5,cronMaxDurationCapMs:72e5},logger:e,writeObs:vi.fn(),getGateway:()=>r,instanceId:"test-instance",thirdPartyChannels:new Set}),state:n,transport:t,gateway:r,logger:e}}describe("schedulePromptTimeout — adopted background (cron) runs",()=>{beforeEach(()=>{vi.useFakeTimers()}),afterEach(()=>{vi.useRealTimers()}),it("fires for a currentRunId-only session: agentCancel + run ended as error",()=>{const e=makeBridge(),n=e.bridge.ensureSessionForKey("agent:main:cron-job-1");n.currentRunId="run-bg-1",n.cronJobId="job-bg-1",n.promptDone=!1,e.bridge.schedulePromptTimeout(n),vi.advanceTimersByTime(300001),expect(e.gateway.agentCancel).toHaveBeenCalledWith(n.gatewaySessionKey,expect.objectContaining({runId:"run-bg-1"})),expect(n.currentRunId).toBeUndefined(),expect(n.promptDone).toBeUndefined(),expect(e.transport.sendSessionUpdate).toHaveBeenCalledWith(n.sessionId,expect.objectContaining({sessionUpdate:"stream_complete",stopReason:"error"}),expect.objectContaining({turnId:"run-bg-1"})),expect(e.transport.sendError).not.toHaveBeenCalled()}),it("stays a no-op for a fully idle session (no pendingPromptId, no currentRunId)",()=>{const e=makeBridge(),n=e.bridge.ensureSessionForKey("agent:main:idle");e.bridge.schedulePromptTimeout(n),vi.advanceTimersByTime(186e4),expect(e.gateway.agentCancel).not.toHaveBeenCalled(),expect(e.transport.sendSessionUpdate).not.toHaveBeenCalled(),expect(n.promptDone).not.toBe(!0)}),it("does not fire after normal completion cleared the timer (no false kill)",()=>{const e=makeBridge(),n=e.bridge.ensureSessionForKey("agent:main:cron-job-2");n.currentRunId="run-bg-2",n.cronJobId="job-bg-2",n.promptDone=!1,e.bridge.schedulePromptTimeout(n),e.bridge.endRun(n,{kind:"complete",stopReason:"end_turn"}),e.gateway.agentCancel.mockClear(),vi.advanceTimersByTime(72e5),expect(e.gateway.agentCancel).not.toHaveBeenCalled()})});
@@ -1 +1 @@
1
- import{randomUUID,createHash}from"node:crypto";import{mkdir,writeFile,readFile,rm,lstat,readlink,stat,readdir,rename,cp}from"node:fs/promises";import path from"node:path";import{join,resolve,relative,sep}from"node:path";import{homedir}from"node:os";import AdmZip from"adm-zip";import{sanitizePromptPayload,stripTransportMetadata,isHeartbeatPromptText,isCronPromptText,extractCronJobId,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{createCronOverridesStore}from"../cron-overrides.js";import{CRON_DURATION_FLOOR_MS}from"../config.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../a2a/mention-resolver.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{loadHistory,groupIntoTurns,extractBeeosFileUrisFromOutput,splitUserReplayBlocks}from"./history-replay.js";import{LocalSessionHistory}from"./local-session-history.js";import{reconcileMissingArtifacts}from"./missing-artifacts-reconciler.js";import{buildResolutionPlan,resolveResources,applyResolutions}from"./prompt-resource-resolver.js";import{readEnrichmentCache,readUploadToolResult}from"../upload/result-cache.js";import{buildToolResultSessionContentBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole}from"./gateway-events.js";import{McpService}from"./mcp-service.js";import{DEFAULT_PROMPT_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types/acp";function narrowContentBlock(e){switch(e.type){case"text":return"string"==typeof e.text?{type:"text",text:e.text}:void 0;case"resource_link":return"string"==typeof e.uri?{type:"resource_link",uri:e.uri,...void 0!==e.name?{name:e.name}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}}:void 0;case"file":return{type:"file",...void 0!==e.text?{text:e.text}:{},...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};case"image":return{type:"image",...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};default:return}}const DEFAULT_MODE={id:"default",name:"Default",description:"Default agent mode"},USER_MESSAGE_PREFIX="User Message From BeeOS:",USER_MESSAGE_PREFIX_WITH_NEWLINE=`${USER_MESSAGE_PREFIX}\n`,SLASH_COMMAND_NAME_RE=/^[a-z0-9_-]+$/i;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",":a2a:",":mcp:",":openapi:"];function isBackgroundSessionKey(e){return BACKGROUND_SESSION_PATTERNS.some(t=>e.includes(t))}function isSystemDefaultSessionKey(e){const t=e.split(":");if(t.length<3||"agent"!==t[0])return!1;const s=t.slice(2).join(":");return"main"===s||s===t[1]}function isPlaceholderTitle(e){return!e||"New chat"===e||(!!/^[0-9a-f]{6,12}\s*\(20\d{2}/i.test(e)||(!!/^Sender\s*\(untrusted/i.test(e)||!!/^Conversation\s+info\s*\(untrusted/i.test(e)))}function cleanGatewayDerivedTitle(e){if(!e)return;let t=stripTransportMetadata(e);return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}function resolveStandaloneSlashCommandName(e,t){if(0===e.length)return;if(e.some(e=>"text"!==e.type))return;const s=t.trim();if(!s||s.includes("\n")||!s.startsWith("/"))return;const n=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return n&&!n.includes("/")&&SLASH_COMMAND_NAME_RE.test(n)?n.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`;return e?`${e}\n${s}`:s}export function decodeBeeosChannelId(e){if(!e)return;const t=BEEOS_CHANNEL_TAG_RE.exec(e);return t?.[1]}export function stripBeeosChannelTag(e){return e?e.replace(/\n?<!-- beeos:channel=.+? -->/,"").trim():""}function deriveSessionId(e,t){const s=createHash("sha256").update(`${t}:${e}`).digest("hex").substring(0,32);return`session:${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20,32)}`}function parseAgentIdFromKey(e){const t=e.split(":");return(t.length>=3&&"agent"===t[0]?t[1]:"")||"main"}function createSessionObject(e){const t=parseAgentIdFromKey(e.gatewayKey);return{sessionId:deriveSessionId(e.gatewayKey,e.instanceId),gatewaySessionKey:e.gatewayKey,agentId:t,platformAgentId:e.resolvePlatformAgentId?.(t),createdAt:Date.now(),chatReplayDedupKeys:new Set,cwd:e.cwd,title:e.title,channelType:resolveChannelFromSessionKey(e.gatewayKey,e.thirdPartyChannels)}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];cronIsolatedSessionKeys=new Set;cronOverrides;setAgentMapping(e){this.agentMapping=e;for(const t of this.state.sessions.values())!t.platformAgentId&&t.agentId&&(t.platformAgentId=e.get(t.agentId)?.id);for(const e of this._syncResolvers)e();this._syncResolvers=[];for(const e of this._mappingReadyCallbacks)e();this._mappingReadyCallbacks=[]}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}markAgentSyncDone(){for(const e of this._syncResolvers)e();this._syncResolvers=[]}waitForAgentMapping(e=15e3){return this.agentMapping?Promise.resolve():new Promise(t=>{this._syncResolvers.push(t);const s=setTimeout(()=>{const e=this._syncResolvers.indexOf(t);-1!==e&&this._syncResolvers.splice(e,1),t()},e);"object"==typeof s&&"unref"in s&&s.unref()})}handleTerminalRequest=null;handleTerminalNotification=null;onRunComplete=null;onRunFailed=null;onRunEnded=null;handleSlashCommand=null;onPromptStart=null;instanceId;constructor(e){this.state=e.state,this.transport=e.transport,this.config=e.config,this.logger=e.logger,this.writeObs=e.writeObs,this.getGateway=e.getGateway,this.instanceId=e.instanceId||"unknown",this.thirdPartyChannels=e.thirdPartyChannels??new Set,this.beeosFileResolver=e.beeosFileResolver,this.agentFetch=e.agentFetch,this.agentGatewayUrl=e.agentGatewayUrl,this.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.cronOverrides=createCronOverridesStore(this.logger),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const n=s?.turnId;if(!n)return;const i=this.state.sessions.get(e);i&&recordTurnIdForRole(i,t,n)})}resolvePlatformAgentId(e){return this.agentMapping?.get(e)?.id}getDefaultAgentId(){return this.agentMapping&&this.agentMapping.size>0?this.agentMapping.keys().next().value??"main":"main"}registerSession(e,t={}){const{ensureVerbose:s=!0}=t;this.state.sessions.has(e.sessionId);this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),s&&this.ensureVerboseFull(e),this.subscribeSessionMessages(e)}async ensureVerboseFull(e){if(e.verbosePatched)return;const t=this.getGateway();if(t?.isConnected){e.verbosePatched=!0;try{await t.sessionsPatch(e.gatewaySessionKey,{verboseLevel:"full"})}catch(t){e.verbosePatched=!1,this.logger.debug?.(`[bridge-core] ensureVerboseFull failed: ${t instanceof Error?t.message:t}`)}}}ensureSessionForKey(e){const t=this.state.findSessionByKey(e);if(t)return t;const s=createSessionObject({gatewayKey:e,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels});return this.registerSession(s),this.logger.debug?.(`[bridge-core] ensureSessionForKey: lazily created session sid=${s.sessionId} key=${e}`),s}async preloadSessionsFromGateway(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.sessionsList();if(!t||"object"!=typeof t)return;const s=t.sessions;if(!Array.isArray(s))return;let n=0;for(const e of s){const t=e,s=t.sessionKey??t.key??t.id??t.name;if(!s||"string"!=typeof s)continue;if(this.state.findSessionByKey(s))continue;const i=t.label||t.derivedTitle||t.title||void 0,o=i&&!isPlaceholderTitle(i)?i:void 0,r=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:o});this.registerSession(r,{ensureVerbose:!1}),n++}n>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${n} session(s) from gateway`)}catch(e){this.logger.debug?.("[bridge-core] preloadSessions failed:",e)}else this.logger.debug?.("[bridge-core] preloadSessions skipped: gateway not connected")}async handleRequest(e){this.writeObs({component:"bridge-core",domain:"forward",name:`rpc.request.${e.method}`,severity:"debug",payload:{id:e.id,method:e.method}});const t=e;try{switch(t.method){case"initialize":this.handleInitialize(t);break;case"ping":this.transport.sendResult(t.id,{pong:!0});break;case"session/new":await this.handleSessionNew(t);break;case"session/load":await this.handleSessionLoad(t);break;case"session/list":await this.handleSessionList(t);break;case"session/rename":this.handleSessionRename(t);break;case"session/delete":await this.handleSessionDelete(t);break;case"session/clear":await this.handleSessionClear(t);break;case"session/prompt":await this.handleSessionPrompt(t);break;case"session/set_mode":await this.handleSessionSetMode(t);break;case"session/set_model":await this.handleSessionSetModel(t);break;case"skills/list":await this.handleSkillsList(t);break;case"skills/install":await this.handleSkillsInstall(t);break;case"skills/uninstall":await this.handleSkillsUninstall(t);break;case"skills/update":await this.handleSkillsUpdate(t);break;case"models/list":await this.handleModelsList(t);break;case"session/cancel":this.handleSessionCancel(t.id,t.params);break;case"cron/list":case"cron/status":case"cron/add":case"cron/update":case"cron/remove":case"cron/run":case"cron/runs":await this.handleCronRequest(t);break;default:"mcp/list"===e.method?await this.mcp.handleList(e):"mcp/set"===e.method?await this.mcp.handleSet(e):"mcp/unset"===e.method?await this.mcp.handleUnset(e):e.method.startsWith("terminal/")&&this.handleTerminalRequest?await this.handleTerminalRequest(e):this.transport.sendAcpError(e.id,Errors.MethodNotFound,{message:`Method not found: ${e.method}`})}}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:t instanceof Error?t.message:"Internal error"})}}handleCanvasNotification=null;getCanvasState=null;onSessionLoaded=null;onSkillInstalled=null;onSessionDeleted=null;onSessionCleared=null;handleNotification(e){switch(e.method){case"session/cancel":this.handleSessionCancel(void 0,e.params);break;case"canvas/toggle":case"canvas/clear":this.handleCanvasNotification?this.handleCanvasNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Canvas notification ignored (canvas disabled): ${e.method}`);break;case"canvas/reference":{const t=e.params,s=t?.surfaceId,n=this.state.resolveSessionFromParams(t);if(!n){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}n.referencedSurfaceId=s||void 0,this.logger.debug?.(`[bridge-core] Canvas reference ${s?"set":"cleared"}: ${s??"(none)"}`);break}default:e.method.startsWith("terminal/")&&this.handleTerminalNotification?this.handleTerminalNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Unknown notification: ${e.method}`)}}handleInitialize(e){this.initialized=!0;const t=this.transport.forwardControl;!1===e.params?.forwardThinking&&(t.forwardThinking=!1),!1===e.params?.forwardToolCalls&&(t.forwardToolCalls=!1),this.transport.sendResult(e.id,{protocolVersion:ACP_PROTOCOL_VERSION,agentCapabilities:{loadSession:!0,skills:!0,multipleSessions:!0,sessionCapabilities:{"web-ssh":!!this.config.bridge.shell.enabled,canvas:!!this.config.canvasEnabled},promptCapabilities:{image:!0,audio:!1,embeddedContext:!0},mcp:{}},agentInfo:{name:"beeos-claw",title:"BeeOS Agent Bridge",version:"0.1.0"},_meta:{},authMethods:[]})}async handleSessionNew(e){const t=`agent:${"string"==typeof e.params?.agentId&&e.params.agentId||this.config.gateway.agentId||this.getDefaultAgentId()}:${randomUUID()}`,s="string"==typeof e.params?.cwd?e.params.cwd:".",n=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(n,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:n.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:n.agentId,platformAgentId:n.platformAgentId??null}}),this.fetchHistoryInBackground(t)}fetchHistoryInBackground(e){const t=this.getGateway();t?.isConnected&&loadHistory(t,e,50).catch(e=>{this.logger.debug?.(`[bridge-core] Background history fetch failed: ${e instanceof Error?e.message:e}`)})}async subscribeSessionMessages(e){if(e.messageSubscriptionActive)return;const t=this.getGateway();if(t?.isConnected)try{await t.sessionsMessagesSubscribe(e.gatewaySessionKey),e.messageSubscriptionActive=!0,this.logger.debug?.(`[bridge-core] session.message subscribed sessionKey=${e.gatewaySessionKey}`)}catch(t){this.logger.debug?.(`[bridge-core] session.message subscribe failed key=${e.gatewaySessionKey}: ${t instanceof Error?t.message:t}`)}}async resubscribeAllSessionMessages(){for(const e of this.state.sessions.values())e.messageSubscriptionActive=!1,await this.subscribeSessionMessages(e)}handleSessionMessage(e){const t=e?.sessionKey,s=e?.messageId;if(!t||!s)return;const n=this.state.sessionsByGatewayKey.get(t);if(!n)return;const i=e.message?.role,o="user"===i?"user":"system"===i?"system":"assistant",r=n.lastTurnIdByRole?.[o];if(!r)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${o} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:r};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"message_id_bind",role:o,messageId:s,...l?{timestamp:l}:{}},d),n.lastTurnIdByRole&&(n.lastTurnIdByRole[o]=void 0),"assistant"===o&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,logger:this.logger,writeObs:this.writeObs,isSessionAlive:e=>this.state.sessions.has(e)},{session:n,turnId:r,messageId:s,runId:void 0})}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const n=s.sessions;if(!Array.isArray(n))return;for(const t of n){const s=t,n=s.sessionKey??s.key??s.id??s.name;if(n&&"string"==typeof n&&deriveSessionId(n,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,i=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(i),i}}}catch(e){this.logger.debug?.("[bridge-core] recoverSessionFromGateway failed:",e)}}async resolveOrCreateSession(e){let t=this.state.sessions.get(e);if(!t&&e.startsWith("agent:")&&(t=this.state.findSessionByKey(e)),t||e.startsWith("agent:")||(t=await this.recoverSessionFromGateway(e)),t)return{session:t,created:!1};const s=e.startsWith("agent:"),n=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${n}:${e}`,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels}),this.registerSession(t),{session:t,created:!0}}async handleSessionLoad(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const{session:s}=await this.resolveOrCreateSession(t),n=s.sessionId;this.state.lastActiveSessionId=n,s.chatReplayDedupKeys.clear(),s.sessionLoadInProgress=!0;try{const e=await this.emitHistoryTurns(s);this.emitCurrentStreamSnapshot(s,e)}finally{s.sessionLoadInProgress=!1}this.transport.sendResult(e.id,{sessionId:n}),this.onSessionLoaded?.(n,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})}async emitHistoryTurns(e){const t=new Set,s=e.sessionId;try{const n=this.getGateway(),i=e.assistantMq&&!e.assistantMq.closed,o=i?null:n,r=await loadHistory(o,e.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:e.agentId,sessionId:e.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}}),a=groupIntoTurns(r),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,h=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${r.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${h}`),l>0&&e.pendingUserTurnUpdates&&e.pendingUserTurnUpdates.length>0&&(e.pendingUserTurnUpdates=[]),a.length>0&&isPlaceholderTitle(e.title)&&"manual"!==e.titleSource){const t=a.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const n=stripTransportMetadata(t.content).trim().slice(0,64).replace(/\n/g," ").trim();if(n){e.title=n,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:n}),this.tryGenerateTitleFromHistory(e,s,a)}}}const p=t=>`history:${e.gatewaySessionKey}:${t}`;let g=0,m=-1;if(i)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){m=e;break}for(let n=0;n<a.length;n++){const o=a[n];if(i&&n===m)this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] assistant — mq active, deferring to emitCurrentStreamSnapshot`);else if("user"!==o.role||"internal_system"!==o.provenance?.kind)if("user"===o.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(o.content))this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] system-injected user message`);else if("user"===o.role&&isHeartbeatPromptText(o.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);else if("user"===o.role&&isCronPromptText(o.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] cron-triggered user message sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);else{if("assistant"===o.role){const t=classifyHeartbeatFilter(o.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);continue}}if("system"!==o.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${n}] role=${o.role} contentLen=${o.content.length} content=${JSON.stringify(o.content.slice(0,80))} tcCount=${o.toolCalls?.length??0}`),"user"===o.role)this.replayUserTurnAsChunks(s,o.content,o.metadata,o.timestamp,p(n),o._seq);else{if(await this.replayAssistantTurnAsStream(s,o,p(n)),o.toolCalls)for(const e of o.toolCalls)e.id&&t.add(e.id);this.transport.sendSessionUpdate(s,{sessionUpdate:"stream_complete",stopReason:"end_turn"},{turnId:p(n)}),g+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${n}] contentLen=${o.content.length}`),this.transport.sendSessionUpdate(s,{sessionUpdate:"system_message_chunk",content:{type:"text",text:o.content},metadata:o.metadata,...o.timestamp?{timestamp:o.timestamp}:{}},{turnId:p(n),...void 0!==o._seq?{seq:o._seq}:{}})}else this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] internal_system user message`)}this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${g} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const n=e.assistantMq;if(n&&!n.closed){const s=n.peek(0);let i=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const n=s[e],o=n.sessionUpdate,r=n.toolCallId;if("tool_call"!==o&&"tool_call_update"!==o||!r||!t.has(r)){if("agent_message_chunk"===o){if(""===(n.content?.text??"").trim()&&e<s.length-1){const n=s[e+1];if(("tool_call"===n.sessionUpdate||"tool_call_update"===n.sessionUpdate)&&n.toolCallId&&t.has(n.toolCallId)){i=e+1;continue}}break}break}i=e+1}const o=resolveTurnId(e),r=o?{turnId:o}:void 0;for(let t=i;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],r)}}async handleSessionList(e){const t=[];for(const[,e]of this.state.sessions)isBackgroundSessionKey(e.gatewaySessionKey)||this.cronIsolatedSessionKeys.has(e.gatewaySessionKey)||t.push({sessionId:e.sessionId,title:e.title||"New chat",cwd:e.cwd,updatedAt:e.lastStreamActivityTs??e.createdAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(e.gatewaySessionKey)});const s=this.getGateway();if(s?.isConnected)try{const e=await s.sessionsList();if(e&&"object"==typeof e&&Array.isArray(e.sessions)){const s=e.sessions;for(const e of s){const s=e,n=s.sessionKey??s.key??s.id??s.name;if(!n||"string"!=typeof n)continue;if(isBackgroundSessionKey(n))continue;if(this.cronIsolatedSessionKeys.has(n))continue;const i=deriveSessionId(n,this.instanceId),o=t.find(e=>e.sessionId===n||e.sessionId===i||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===n),r=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=r&&!isPlaceholderTitle(r)?r:void 0,l=s.model,d=s.modelProvider;if(o){if(a&&isPlaceholderTitle(o.title)){o.title=a;const e=this.state.sessions.get(o.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(o.updatedAt=e),l&&(o.model=l),d&&(o.modelProvider=d)}else{const e=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s.cwd,title:a});this.registerSession(e,{ensureVerbose:!1}),t.push({sessionId:e.sessionId,title:a||"",cwd:s.cwd,updatedAt:s.updatedAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(n),model:l,modelProvider:d})}}}}catch(e){this.logger.debug?.("[bridge-core] sessions.list enrichment failed:",e)}t.sort((e,t)=>(t.updatedAt??0)-(e.updatedAt??0)||e.sessionId.localeCompare(t.sessionId)),this.transport.sendResult(e.id,{sessions:t})}handleSessionRename(e){const t=e.params?.sessionId,s=e.params?.title;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, title"});const n=s.slice(0,64),i=this.state.sessions.get(t);if(!i)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);i.title=n,i.titleSource="manual";const o=i.gatewaySessionKey,r=this.getGateway();r?.isConnected&&r.sessionsPatch(o,{label:n}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:n})}static TITLE_TEMPLATES=[{match:["今日日报","日报"],zh:"今日工作日报",en:"Daily Work Log"},{match:["周报","周报总结","本周周报"],zh:"本周工作周报",en:"Weekly Work Report"},{match:["月报","月度汇报","月度总结"],zh:"本月工作月报",en:"Monthly Work Report"},{match:["工作日志","日志"],zh:"工作日志整理",en:"Work Log Review"},{match:["晨会","今日晨会"],zh:"今日晨会纪要",en:"Daily Standup Notes"},{match:["复盘","今日复盘"],zh:"今日工作复盘",en:"Daily Retrospective"},{match:["新会话","new chat","new conversation"],zh:"新会话",en:"New Conversation"}];static TITLE_SYSTEM_PROMPT='You are a conversation title generator. Your task is to generate a concise, human-readable title summarizing the topic of the user\'s question together with the assistant\'s answer.\n\n### Rules:\n- Length: 3-6 words in English, or 6-20 characters in Chinese (up to 24 if needed to keep all key domain terms intact).\n- Language: Match the user\'s input language exactly. Mixed zh/en input → use the dominant language.\n- Extract the core topic (noun-centric) of the Q&A exchange, not the action verb.\n- Use the assistant\'s answer to disambiguate or enrich the topic when the question alone is vague (e.g. "帮我看下" + an answer about x402 → "x402 支付集成").\n- No punctuation, no quotation marks, no emojis, no markdown.\n- No prefix like "Title:" or "Here is". Output the title only.\n- Avoid generic words: "help", "question", "chat", "problem", "task".\n- If both the question and answer are too vague to summarize, return "新会话" (for zh) or "New Conversation" (for en).\n\n### Input Format:\nThe user message will contain both the question and the assistant\'s answer, delimited like this:\n[User Question]\n...question text...\n\n[Assistant Answer]\n...answer text (may be truncated with "..." in the middle)...\n\n### Output Format:\nRespond ONLY with valid JSON, no other text:\n{"title": "<your title here>"}\n\n### Examples:\nInput:\n[User Question]\n帮我写一个关于BeeOS设备接入的PRD\n\n[Assistant Answer]\n好的,这是 BeeOS 设备接入 PRD 的草稿:1. 目标 ...\nOutput: {"title": "BeeOS 设备接入 PRD"}\n\nInput:\n[User Question]\n帮我看下\n\n[Assistant Answer]\n你说的 x402 支付协议是一个基于 HTTP 402 的链上支付标准 ...\nOutput: {"title": "x402 支付协议解析"}\n\nInput:\n[User Question]\nWhat\'s the best way to integrate x402 payment protocol?\n\n[Assistant Answer]\nYou have three options: 1. Use the official SDK ...\nOutput: {"title": "x402 Payment Integration"}\n\nInput:\n[User Question]\nhi\n\n[Assistant Answer]\nHello! How can I help you today?\nOutput: {"title": "New Conversation"}';matchTitleTemplate(e){const t=e.trim().toLowerCase();for(const s of AcpGatewayBridgeCore.TITLE_TEMPLATES)if(s.match.some(e=>e.toLowerCase()===t))return/[\u4e00-\u9fff]/.test(e)?s.zh:s.en}isGuardMessage(e){const t=e.trim();return[...t].length<3||[...t].every(e=>/[\p{P}\p{S}\s]/u.test(e))}truncateQuestionForTitle(e){return e.length>2e3?e.slice(0,1500):e}truncateAnswerForTitle(e){return e.length<=1e3?e:`${e.slice(0,500)}\n...\n${e.slice(-500)}`}generateTitleFromQA(e,t,s,n){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const i=this.matchTitleTemplate(s);if(i){e.title=i,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:i}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:i})}const o=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),r=process.env.AROUTER_API_KEY||"";if(!o||!r)return void this.logger.info?.(`[bridge-core] title-gen SKIP sid=${t}: no AROUTER env vars`);const a=`[User Question]\n${this.truncateQuestionForTitle(s)}\n\n[Assistant Answer]\n${this.truncateAnswerForTitle(n)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${n.length}`),fetch(`${o}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify({model:"openai/gpt-4o-mini",max_tokens:32,temperature:.3,messages:[{role:"system",content:AcpGatewayBridgeCore.TITLE_SYSTEM_PROMPT},{role:"user",content:a}]})}).then(async s=>{if(!s.ok){const e=await s.text().catch(()=>"");return void this.logger.info?.(`[bridge-core] title-gen FAILED sid=${t} status=${s.status} body=${e.slice(0,300)}`)}const n=await s.json(),i=n.choices?.[0]?.message?.content?.trim();if(!i)return;const o=i.replace(/^```json\s*/,"").replace(/```$/,"").trim();let r;try{r=JSON.parse(o).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${o}`)}if(!r)return;if("manual"===e.titleSource)return void this.logger.debug?.("[bridge-core] title-gen discarded: user already renamed");this.logger.info?.(`[bridge-core] title-gen OK sid=${t} title="${r}"`),e.title=r,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:r})}).catch(e=>{this.logger.info?.(`[bridge-core] title-gen ERROR sid=${t}: ${e instanceof Error?e.message:e}`)})}async tryGenerateTitleFromHistory(e,t,s){if("manual"===e.titleSource||"auto"===e.titleSource)return;const n=this.getGateway();if(!n?.isConnected)return;let i=s;if(!i)try{const t=await loadHistory(n,e.gatewaySessionKey);i=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const o=i.find(e=>{if("user"!==e.role)return!1;if(!e.content?.trim())return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!o)return;const r=i.indexOf(o),a=i.slice(r+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=stripTransportMetadata(o.content).trim(),d=a.content.trim();l&&d&&this.generateTitleFromQA(e,t,l,d)}async handleSessionDelete(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.info?.(`[bridge-core] session/delete sessionId=${t} NOT FOUND in local state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);const n=s.gatewaySessionKey;this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${n} gwConnected=${!!this.getGateway()?.isConnected}`);let i=!1;const o=this.getGateway();if(o?.isConnected&&n)try{await o.sessionsDelete(n),i=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${n}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${n}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await o.sessionsReset(n),i=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${n}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${n}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else n&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${n}`);void 0!==s.pendingPromptId&&this.failPrompt(s,Errors.SessionDeleted.rpcCode,Errors.SessionDeleted.message),this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const r=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),r.add(e));for(const[e,t]of this.state.toolCallIdMap)r.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(n),this.transport.resetSessionUpdateCount(t),i&&this.onSessionDeleted?.(n),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${n} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}async handleSessionClear(e){this.logger.info?.(`[bridge-core] handleSessionClear called, params=${JSON.stringify(e.params)}`);const t=e.params?.sessionId;if(!t)return this.logger.warn?.("[bridge-core] session/clear missing sessionId"),void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.warn?.(`[bridge-core] session/clear sessionId=${t} not found in state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);this.logger.info?.(`[bridge-core] session/clear sessionId=${t} gatewayKey=${s.gatewaySessionKey}`);const n=this.getGateway(),i=n?.isConnected;if(s.promptDone||void 0===s.pendingPromptId&&void 0===s.currentRunId||(this.logger.info?.(`[bridge-core] session/clear: cancelling active run sid=${t} runId=${s.currentRunId}`),i&&(s.currentRunId||s.gatewayRequestId)&&n.agentCancel(s.gatewaySessionKey,{runId:s.currentRunId,requestId:s.gatewayRequestId}),this.endRun(s,{kind:"complete",stopReason:"cancelled"})),i&&s.gatewaySessionKey)try{await n.sessionsReset(s.gatewaySessionKey),this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${s.gatewaySessionKey}`)}catch(e){this.logger.warn?.(`[bridge-core] Gateway sessions.reset FAILED for key=${s.gatewaySessionKey}: ${e instanceof Error?e.message:e}`)}else this.logger.warn?.(`[bridge-core] session/clear skipped gateway reset: connected=${i}, key=${s.gatewaySessionKey}`);s.chatReplayDedupKeys.clear(),this.logger.info?.("[bridge-core] session/clear dedupKeys cleared, calling onSessionCleared"),this.onSessionCleared?.(t,s.gatewaySessionKey),this.transport.sendResult(e.id,{cleared:!0}),this.logger.info?.("[bridge-core] session/clear DONE, sent cleared=true")}async handleSessionSetMode(e){const t=e.params?.sessionId,s=e.params?.mode,n=t?this.state.sessions.get(t):void 0,i=this.getGateway();if(n&&i?.isConnected&&s){const e={};if("verbose"===s||"detailed"===s?e.verboseLevel="full":"concise"!==s&&"default"!==s||(e.verboseLevel="default"),"reasoning"!==s&&"thinking"!==s||(e.reasoningLevel="stream"),Object.keys(e).length>0)try{await i.sessionsPatch(n.gatewaySessionKey,e)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch failed: ${e instanceof Error?e.message:e}`)}}this.transport.sendResult(e.id,{mode:s??"default"})}async handleSessionSetModel(e){const t=e.params?.sessionId,s=e.params?.model,n=t?this.state.sessions.get(t):void 0,i=this.getGateway();let o=s??null,r=null;if(n&&i?.isConnected&&s)try{const e=await i.sessionsPatch(n.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(o=t.model),t?.modelProvider&&(r=t.modelProvider)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch for model failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{model:o,modelProvider:r})}async handleModelsList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("models.list",{});this.transport.sendResult(e.id,s??{models:[]})}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list models: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleSkillsList(e){const t=await this.fetchSimplifiedSkills();this.transport.sendResult(e.id,{skills:t})}async fetchSimplifiedSkills(){const e=this.getGateway();if(!e?.isConnected)return[];try{const t=await e.skillsStatus();if(!t?.skills)return[];const s=t.skills.filter(e=>!1!==e.eligible||!0===e.disabled).sort((e,t)=>e.always&&!t.always?-1:!e.always&&t.always?1:e.name.localeCompare(t.name)).map(e=>({name:e.name,description:e.description,emoji:e.emoji,always:e.always||void 0,bundled:e.bundled||void 0,disabled:e.disabled||void 0,skillKey:e.skillKey||void 0,iconUrl:void 0})),n=this.getSkillsDir();for(const e of s){const t=e.skillKey??e.name;try{const s=await readFile(join(n,t,".beeos-meta.json"),"utf8"),i=JSON.parse(s);i?.iconUrl&&(e.iconUrl=i.iconUrl)}catch{}}return s}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(){const e=await this.fetchSimplifiedSkills();this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:e}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:e.length}})}getSkillsDir(){const e=process.env.OPENCLAW_HOME||join(homedir(),".openclaw");return join(e,"skills")}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,n=t?.files;let i;try{i=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!n?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(this.getSkillsDir(),i);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(n?.length){const e=resolve(o);for(const t of n){const s=join(o,t.path);if(!resolve(s).startsWith(e+sep))throw new Error(`File path escapes skill directory: ${t.path}`);await mkdir(join(s,".."),{recursive:!0});const n=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!n.ok)throw new Error(`Failed to download ${t.path}: ${n.status}`);const i=Buffer.from(await n.arrayBuffer());await writeFile(s,i)}}if(t?.iconUrl)try{await writeFile(join(o,".beeos-meta.json"),JSON.stringify({iconUrl:t.iconUrl}),"utf8")}catch{}this.logger.info?.(`[bridge-core] Installed skill "${i}" via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${i}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Install failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUninstall(e){const t=e.params;let s;try{s=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const n=resolveSkillInstallDir(this.getSkillsDir(),s),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),s);try{let t=!1;try{const e=await lstat(n);if(e.isSymbolicLink()){const e=await readlink(n);await rm(n,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(n,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(i);(e.isDirectory()||e.isSymbolicLink())&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${s}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${s}"`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Uninstall failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUpdate(e){const t=e.params,s=t?.skillKey,n=t?.enabled;if(!s||"boolean"!=typeof n)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const i=this.getGateway();if(i?.isConnected)try{const t=await i.skillsUpdate(s,n);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${n}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:n}),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to update skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.InternalError,{message:"Gateway not connected"})}async handleSessionPrompt(e){const t=e.params?.sessionId,s=e.params?.prompt;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, prompt"});const{session:n,created:i}=await this.resolveOrCreateSession(t);i&&this.onSessionLoaded?.(n.sessionId,n.gatewaySessionKey,n.platformAgentId);const o=n.sessionId;this.state.lastActiveSessionId=o,this.onPromptStart?.();const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);await this.ensureVerboseFull(n);let a=extractPromptBlocks(sanitizePromptPayload(s));if(!n.title||isPlaceholderTitle(n.title)){const e=a.find(e=>"text"===e.type&&e.text);if(e&&"text"in e&&e.text){const t=stripTransportMetadata(e.text).trim();if(this.logger.info?.(`[bridge-core] title-gen sid=${o} existing="${n.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();n.title=e,n.titleSource="truncated",r?.isConnected&&e&&r.sessionsPatch(n.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(o,{sessionUpdate:"title_update",title:e})}}}const l=buildResolutionPlan(a);if(l.refs.length>0)try{const e=await resolveResources(l,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let d;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{d=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const c=toGatewayPrompt(a,{beeosFileResolutionsByUri:d});if("error"in c)return void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:c.error});const{message:h,attachments:p}=c,g=resolveStandaloneSlashCommandName(a,h)??resolveSlashCmd(a,h),m="string"==typeof g;if(g&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(g)}catch(e){return t.logger.warn?.(`[bridge-core] /${g} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(n,a,resolveTurnId(n)),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${g}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(n)}),void this.transport.sendResult(e.id,{stopReason:"end_turn"})}const u="chat.send";let f=m?h:withUserMessagePrefix(h);const y=this.config.canvasEnabled&&!m&&!1!==n.canvasActive&&!n.channelType;if(y){const e=n.promptsSinceCanvasInjection??0;!n.canvasPromptInjected||e>=10?(f=`${CANVAS_TERM_MAPPING}\n\n${f}`,n.canvasPromptInjected=!0,n.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):n.promptsSinceCanvasInjection=e+1}if(y){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(n.gatewaySessionKey);f=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${f}`}const t=n.referencedSurfaceId;t&&(f=`${CANVAS_UPDATE_HINT}\n[IMPORTANT: The user selected canvas surface "${t}" for editing. You MUST call render_ui with id="${t}" to UPDATE this existing surface. Do NOT create a new surface with a different id.]\n${f}`,n.referencedSurfaceId=void 0)}if(!m){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){n.activeMentions=t;f=`${buildMentionInstruction(t)}\n${f}`,this.logger.debug?.(`[bridge-core] A2A mention instruction injected for ${t.length} agent(s)`)}else n.activeMentions=void 0}m&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${o} command=/${g}`),n.promptDone||void 0===n.pendingPromptId&&void 0===n.currentRunId||(this.logger.warn?.(`[bridge-core] handleSessionPrompt: force-ending stale run sid=${o} oldRunId=${n.currentRunId} oldPromptId=${n.pendingPromptId}`),(n.currentRunId||n.gatewayRequestId)&&r.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId}),this.endRun(n,{kind:"complete",stopReason:"cancelled"})),this.state.resetForNewRun(n,o,this.transport),n.pendingPromptId=e.id,n.gatewayMethod=u;const S=resolveTurnId(n);this.emitPromptAsUserSessionUpdates(n,a,S),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},S?{turnId:S}:void 0);const w=extractCronJobId(h);w&&(n.cronJobId=w),this.schedulePromptTimeout(n);const I=p.length>0?p.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,b=`req_${randomUUID().replace(/-/g,"")}`,v={sessionKey:n.gatewaySessionKey,message:f,deliver:!1,idempotencyKey:randomUUID()};I?.length&&(v.attachments=I);if(!r.sendFrame({type:"req",id:b,method:u,params:v}))return this.state.clearPromptTimeout(n),n.pendingPromptId=void 0,n.assistantMq?.close(),n.assistantMq=void 0,n.pendingUserTurnUpdates=void 0,void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});n.gatewayRequestId=b,this.state.promptsByGatewayRequestId.set(b,n)}replayUserTurnAsChunks(e,t,s,n,i,o){const r=splitUserReplayBlocks(t),a=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const t of r){let i;if("text"===t.type){if(!t.text)continue;i={type:"text",text:t.text}}else i="resource_link"===t.type?{type:"resource_link",uri:t.uri??"",...t.fileName?{name:t.fileName}:{},...t.mimeType?{mimeType:t.mimeType}:{}}:{type:t.type,...t.mimeType?{mimeType:t.mimeType}:{},...t.fileName?{fileName:t.fileName}:{}};this.transport.sendSessionUpdate(e,{sessionUpdate:"user_message_chunk",content:i,...s?{metadata:s}:{},...n?{timestamp:n}:{}},a)}}async replayAssistantTurnAsStream(e,t,s){const n=e=>{if(s||void 0!==e)return{...s?{turnId:s}:{},...void 0!==e?{seq:e}:{}}},i=n(t._seq);if(!t.sourceMessages?.length){const s={sessionUpdate:"history_turn",role:t.role,content:t.content,reasoning:t.reasoning,toolCalls:t.toolCalls,...t.timestamp?{timestamp:t.timestamp}:{}};return t.metadata&&(s.metadata=t.metadata),void this.transport.sendSessionUpdate(e,s,i)}for(const s of t.sourceMessages){const i=s.updateType??"agent_message_chunk",o=n(s._seq);switch(i){case"agent_message_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{},...s.metadata?{metadata:s.metadata}:{}},o);break;case"agent_thought_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{}},o);break;case"tool_call":{const n=s.toolCallId??"",i=t.toolCalls?.find(e=>e.id===n),r=s.toolName??"tool",a=s.content??"";let l={};if(a)try{l=JSON.parse(a)}catch{}this.transport.sendSessionUpdate(e,buildToolCallPayload({toolCallId:n,toolName:r,args:l,kind:i?.kind,status:i?.status??"in_progress",timestamp:s.timestamp}),o);break}case"tool_call_update":{const n=s.toolCallId??"",i=t.toolCalls?.find(e=>e.id===n),r=s.toolName??i?.name??"tool",a=s.content??i?.output??"";let l=await readEnrichmentCache(n).catch(()=>{});if(!l){const e=extractBeeosFileUrisFromOutput(a);e.length>0&&(l=e.map(e=>({uri:e,name:"file"})))}if(!l&&"beeos_upload_file"===r){const e=await readUploadToolResult(n).catch(()=>{});if(e){const t=buildToolResultSessionContentBlocks(r,e).filter(e=>"resource_link"===e.content?.type&&e.content.uri).map(e=>({uri:e.content.uri,name:e.content.name??"file",...e.content.mimeType?{mimeType:e.content.mimeType}:{}}));t.length>0&&(l=t)}}this.transport.sendSessionUpdate(e,buildToolCallUpdatePayload({toolCallId:n,toolName:r,outputText:a,kind:i?.kind,status:i?.status??"completed",resourceLinks:l,timestamp:s.timestamp}),o);break}}}}emitPromptAsUserSessionUpdates(e,t,s){const n=(new Date).toISOString(),i=s?{turnId:s}:void 0;for(const o of t){const t=this.toUserSessionUpdateContent(o);if(!t)continue;const r={sessionUpdate:"user_message_chunk",content:t,timestamp:n};s&&void 0===r._turnId&&(r._turnId=s),this.transport.sendSessionUpdate(e.sessionId,r,i),e.pendingUserTurnUpdates?.push(r)}}toUserSessionUpdateContent(e){if("text"===e.type){if(!e.text)return;return{type:"text",text:e.text}}if("image"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.name;if(!e.data&&!e.uri)return;return{type:"image",...e.data?{data:e.data}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource_link"===e.type){if(!e.uri)return;return{type:"resource_link",uri:e.uri,...e.title?{title:e.title}:{},...e.name?{name:e.name}:{},...e.mimeType??e.mime_type?{mimeType:e.mimeType??e.mime_type}:{}}}if("file"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.filename??e.name;if(!e.data&&!e.text&&!e.uri)return;return{type:"file",...e.data?{data:e.data}:{},...e.text?{text:e.text}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource"===e.type){const t=e.resource??{},s=t.uri??e.uri,n=t.mimeType??t.mime_type??e.mimeType??e.mime_type,i=t.text??e.text,o=t.data??e.data,r=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!i&&!o)return;return{type:"resource",resource:{...s?{uri:s}:{},...n?{mimeType:n}:{},...i?{text:i}:{},...o?{data:o}:{},...r?{fileName:r}:{}}}}}handleSessionCancel(e,t){const s=t&&"object"==typeof t&&"sessionId"in t?t.sessionId:void 0;if(!s)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.MissingParam,{param:"sessionId"}));const n=this.state.sessions.get(s);if(!n)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const i=this.getGateway(),o=i?.isConnected,r=!(!n.currentRunId&&!n.gatewayRequestId);if(o&&r){i.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId})}this.endRun(n,{kind:"complete",stopReason:"cancelled"})||this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"stream_complete",stopReason:"cancelled"},{turnId:resolveTurnId(n)}),void 0!==e&&this.transport.sendResult(e,{})}endRun(e,t){if(e.promptDone)return this.logger.debug?.(`[bridge-core] endRun skipped (promptDone=true) sessionId=${e.sessionId} outcome=${t.kind}`),!1;if(void 0===e.pendingPromptId&&void 0===e.currentRunId)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;const s=resolveTurnId(e),n=s?{turnId:s}:void 0;e.promptDone=!0;const i=e.currentRunId;if("complete"===t.kind){if(i&&this.onRunComplete){const t=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(i,t)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:t.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:t.stopReason},n),this.tryGenerateTitleFromHistory(e,e.sessionId)}else i&&this.onRunFailed&&this.onRunFailed(i,t.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,t.code,t.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:t.message},n);return this.cleanupPrompt(e),this.onRunEnded?.(e,t),!0}schedulePromptTimeout(e){let t;if(this.state.clearPromptTimeout(e),e.cronJobId){const s=this.cronOverrides.get(e.cronJobId)??this.config.cronMaxDurationDefaultMs;t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,s))}else t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{void 0!==e.pendingPromptId&&((e.currentRunId||e.gatewayRequestId)&&this.getGateway()?.agentCancel(e.gatewaySessionKey,{runId:e.currentRunId,requestId:e.gatewayRequestId}),this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),this.failPrompt(e,Errors.PromptTimeout.rpcCode,`Prompt timed out after ${t}ms`))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.delete(t),this.state.runIdToCronJobId.delete(t),this.state.markRunCompleted(t);const e=`tc_${t}_`;for(const[t,s]of this.state.toolCallIdMap)s.startsWith(e)&&this.state.toolCallIdMap.delete(t)}e.gatewayRequestId&&this.state.promptsByGatewayRequestId.delete(e.gatewayRequestId),e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,e.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}completePrompt(e,t){this.endRun(e,{kind:"complete",stopReason:t})}failPrompt(e,t,s){this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())this.failPrompt(e,Errors.GatewayDisconnected.rpcCode,Errors.GatewayDisconnected.message)}handleGatewayResponse(e){const t=e.id;if(!t)return;const s=this.state.promptsByGatewayRequestId.get(t);if(!s||s.promptDone)return;if(!e.ok){const n=e.error,i=n?.message??"gateway returned error";return this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${i}`),void this.failPrompt(s,Errors.SendFailed.rpcCode,i)}const n=e.payload,i=n?.runId;i?(s.currentRunId=i,s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(i,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${i} gatewayRequestId=${t} sessionId=${s.sessionId}`)):this.logger.debug?.(`[bridge-core] handleGatewayResponse: no runId in payload gatewayRequestId=${t} sessionId=${s.sessionId}`)}maybeCompletePromptFromChatState(e,t){if(e.promptDone||"chat.send"!==e.gatewayMethod)return;const s="string"==typeof t.state?t.state.toLowerCase():void 0;if(s)if("final"===s)this.completePrompt(e,"end_turn");else if("aborted"===s||"cancelled"===s||"cancel"===s)this.completePrompt(e,"cancelled");else if("error"===s){const s=("string"==typeof t.summary?t.summary:void 0)??("string"==typeof t.message?t.message:void 0)??"gateway chat error";this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}pushToolResultContent(e,t,s,n){if(0===s.length)return;const i=n?this.state.promptsByRunId.get(n):void 0;if(!i)return void this.logger.warn?.(`[bridge-core] pushToolResultContent dropped — no session for runId=${n??"(none)"}`);const o=i.sessionId,r=(new Date).toISOString(),a={turnId:resolveTurnId(i)};for(const e of s){const t=narrowContentBlock(e);if(!t)continue;const s={sessionUpdate:"agent_message_chunk",content:t,timestamp:r};this.transport.sendSessionUpdate(o,s,a),i.assistantMq?.push(s)}this.writeObs({component:"bridge-core",domain:"tool",name:"tool_result.direct_push",severity:"info",payload:{toolCallId:e,toolName:t,blockCount:s.length,sessionId:o}})}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),n={...e.params??{}},i="cron.update"===s;!i||n.patch&&"object"==typeof n.patch||(n.patch={});const o=i?n.patch:n,r=o._beeos_meta;if(delete o._beeos_meta,"cron.add"!==s&&"cron.update"!==s||!0!==o._beeos_internal)void 0!==o._beeos_internal&&delete o._beeos_internal;else{delete o._beeos_internal,o.delivery={mode:"none"};const e=await this.allocateCronChannel(o.name??"cron");e&&(o.description=encodeBeeosChannelTag(o.description,e))}try{const i=await t.request(s,n),a="cron.list"===s||"cron.status"===s?this.enrichCronResponseWithOverrides(i):i;if(this.transport.sendResult(e.id,a??{}),("cron.add"===s||"cron.update"===s)&&i&&"object"==typeof i){const e=i.id;if(e&&void 0!==r?.maxDurationMs){const t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,r.maxDurationMs));this.cronOverrides.set(e,t)}}if("cron.remove"===s){const t=e.params?.id;t&&this.cronOverrides.delete(t)}if(("cron.add"===s||"cron.update"===s)&&i&&"object"==typeof i){const e=i.id,t=decodeBeeosChannelId(o.description);e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const n=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${n}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:n})}}enrichCronResponseWithOverrides(e){if(!e||"object"!=typeof e)return e;const t=e,s=e=>{if(!e||"object"!=typeof e)return e;const t=e,s=t.id;if("string"!=typeof s)return e;const n=this.cronOverrides.get(s);return"number"!=typeof n?e:{...t,_beeos_meta:{maxDurationMs:n}}};if(Array.isArray(t.jobs))return{...t,jobs:t.jobs.map(s)};if(t.jobs&&"object"==typeof t.jobs){const e=t.jobs,n={};for(const[t,i]of Object.entries(e))n[t]=s(i);return{...t,jobs:n}}return e}async allocateCronChannel(e){if(this.agentFetch&&this.agentGatewayUrl)try{const t=`${this.agentGatewayUrl}/api/v1/channels`,s=await this.agentFetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({participants:[this.instanceId,"service:cron"],metadata:{source:"cron",job_name:e}})});if(!s.ok)return void this.logger.warn?.(`[bridge-core] channel alloc failed: ${s.status}`);return(await s.json()).id}catch(e){return void this.logger.warn?.(`[bridge-core] channel alloc error: ${e instanceof Error?e.message:e}`)}else this.logger.debug?.("[bridge-core] agentFetch/agentGatewayUrl unavailable, skip channel alloc")}setCronChannelCache(e,t){if(this.cronChannelCache.size>=AcpGatewayBridgeCore.CRON_CHANNEL_CACHE_MAX){const e=this.cronChannelCache.keys().next().value;void 0!==e&&this.cronChannelCache.delete(e)}this.cronChannelCache.set(e,t)}async refreshCronIsolatedSessionKeys(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.request("cron.list",{}),s=t?.jobs;if(!Array.isArray(s))return;const n=new Set,i=[];for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("string"==typeof t.id&&i.push(t.id),"isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&n.add(s)}this.cronIsolatedSessionKeys=n,this.cronOverrides.reconcile(i)}catch(e){this.logger.debug?.(`[bridge-core] refreshCronIsolatedSessionKeys failed: ${e instanceof Error?e.message:e}`)}}}async function fetchWithRetry(e,t={}){const s=t.maxRetries??3;let n;for(let i=0;i<=s;i++){const o=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(o.ok)return Buffer.from(await o.arrayBuffer());if(429===o.status||o.status>=500){const r=Number(o.headers.get("retry-after"))||0,a=1e3*Math.max(r,2**i);if(n=new Error(`HTTP ${o.status} from ${e}`),i<s){t.logger?.info?.(`[bridge-core] Download ${o.status}, retry ${i+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${o.status}`)}throw n??new Error("Download failed after retries")}const SKILL_ROOT_MARKERS=["SKILL.md","skill.md","SKILL.MD"];async function resolveSkillRoot(e){for(const t of SKILL_ROOT_MARKERS)try{return await stat(join(e,t)),e}catch{}const t=(await readdir(e,{withFileTypes:!0})).filter(e=>e.isDirectory()&&!e.name.startsWith(".")&&"__MACOSX"!==e.name);if(1===t.length){const s=join(e,t[0].name);for(const e of SKILL_ROOT_MARKERS)try{return await stat(join(s,e)),s}catch{}return s}return e}async function extractZipToDir(e,t){const s=new AdmZip(e),n=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(n,{recursive:!0}),s.extractAllTo(n,!0);const e=await resolveSkillRoot(n),i=await readdir(e,{withFileTypes:!0});let o=0;for(const s of i){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const n=join(e,s.name),i=join(t,s.name);await rename(n,i).catch(async()=>{await cp(n,i,{recursive:!0})}),o++}if(0===o)throw new Error("Zip archive contained no extractable files")}finally{await rm(n,{recursive:!0,force:!0}).catch(()=>{})}}const VALID_SLUG_PATTERN=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,NON_ASCII_PATTERN=/[^\x00-\x7F]/;function validateSkillSlug(e){const t=e.trim();if(!t||t.includes("/")||t.includes("\\")||t.includes(".."))throw new Error(`Invalid skill slug: ${e}`);if(NON_ASCII_PATTERN.test(t)||!VALID_SLUG_PATTERN.test(t))throw new Error(`Invalid skill slug: ${e}`);return t}function safeDirName(e){const t=e.trim();return t?t.replaceAll("/","__").replaceAll("\\","__"):t}function resolveSkillInstallDir(e,t){const s=safeDirName(t),n=join(e,s),i=resolve(e),o=resolve(n),r=relative(i,o);if(!r||".."===r||r.startsWith(`..${sep}`)||path.isAbsolute(r))throw new Error("Invalid skill target path");return n}
1
+ import{randomUUID,createHash}from"node:crypto";import{mkdir,writeFile,readFile,rm,lstat,readlink,stat,readdir,rename,cp}from"node:fs/promises";import path from"node:path";import{join,resolve,relative,sep}from"node:path";import{homedir}from"node:os";import AdmZip from"adm-zip";import{sanitizePromptPayload,stripTransportMetadata,isHeartbeatPromptText,isCronPromptText,extractCronJobId,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{createCronOverridesStore}from"../cron-overrides.js";import{CRON_DURATION_FLOOR_MS}from"../config.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../delegation/mention-resolver.js";import{EXTERNAL_PROTOCOL_TAGS}from"./external-protocol-tags.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{loadHistory,groupIntoTurns,extractBeeosFileUrisFromOutput,splitUserReplayBlocks}from"./history-replay.js";import{LocalSessionHistory}from"./local-session-history.js";import{reconcileMissingArtifacts}from"./missing-artifacts-reconciler.js";import{buildResolutionPlan,resolveResources,applyResolutions}from"./prompt-resource-resolver.js";import{readEnrichmentCache,readUploadToolResult}from"../upload/result-cache.js";import{buildToolResultSessionContentBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole}from"./gateway-events.js";import{McpService}from"./mcp-service.js";import{DEFAULT_PROMPT_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types/acp";function narrowContentBlock(e){switch(e.type){case"text":return"string"==typeof e.text?{type:"text",text:e.text}:void 0;case"resource_link":return"string"==typeof e.uri?{type:"resource_link",uri:e.uri,...void 0!==e.name?{name:e.name}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}}:void 0;case"file":return{type:"file",...void 0!==e.text?{text:e.text}:{},...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};case"image":return{type:"image",...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};default:return}}const DEFAULT_MODE={id:"default",name:"Default",description:"Default agent mode"},USER_MESSAGE_PREFIX="User Message From BeeOS:",USER_MESSAGE_PREFIX_WITH_NEWLINE=`${USER_MESSAGE_PREFIX}\n`,SLASH_COMMAND_NAME_RE=/^[a-z0-9_-]+$/i;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",...EXTERNAL_PROTOCOL_TAGS];function isBackgroundSessionKey(e){return BACKGROUND_SESSION_PATTERNS.some(t=>e.includes(t))}function isSystemDefaultSessionKey(e){const t=e.split(":");if(t.length<3||"agent"!==t[0])return!1;const s=t.slice(2).join(":");return"main"===s||s===t[1]}function isPlaceholderTitle(e){return!e||"New chat"===e||(!!/^[0-9a-f]{6,12}\s*\(20\d{2}/i.test(e)||(!!/^Sender\s*\(untrusted/i.test(e)||!!/^Conversation\s+info\s*\(untrusted/i.test(e)))}function cleanGatewayDerivedTitle(e){if(!e)return;let t=stripTransportMetadata(e);return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}function resolveStandaloneSlashCommandName(e,t){if(0===e.length)return;if(e.some(e=>"text"!==e.type))return;const s=t.trim();if(!s||s.includes("\n")||!s.startsWith("/"))return;const n=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return n&&!n.includes("/")&&SLASH_COMMAND_NAME_RE.test(n)?n.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`,n=stripBeeosChannelTag(e);return n?`${n}\n${s}`:s}export function decodeBeeosChannelId(e){if(!e)return;const t=BEEOS_CHANNEL_TAG_RE.exec(e);return t?.[1]}export function stripBeeosChannelTag(e){return e?e.replace(/\n?<!-- beeos:channel=.+? -->/g,"").trim():""}function deriveSessionId(e,t){const s=createHash("sha256").update(`${t}:${e}`).digest("hex").substring(0,32);return`session:${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20,32)}`}function parseAgentIdFromKey(e){const t=e.split(":");return(t.length>=3&&"agent"===t[0]?t[1]:"")||"main"}function createSessionObject(e){const t=parseAgentIdFromKey(e.gatewayKey);return{sessionId:deriveSessionId(e.gatewayKey,e.instanceId),gatewaySessionKey:e.gatewayKey,agentId:t,platformAgentId:e.resolvePlatformAgentId?.(t),createdAt:Date.now(),chatReplayDedupKeys:new Set,cwd:e.cwd,title:e.title,channelType:resolveChannelFromSessionKey(e.gatewayKey,e.thirdPartyChannels)}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];cronIsolatedSessionKeys=new Set;cronOverrides;setAgentMapping(e){this.agentMapping=e;for(const t of this.state.sessions.values())!t.platformAgentId&&t.agentId&&(t.platformAgentId=e.get(t.agentId)?.id);for(const e of this._syncResolvers)e();this._syncResolvers=[];for(const e of this._mappingReadyCallbacks)e();this._mappingReadyCallbacks=[]}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}markAgentSyncDone(){for(const e of this._syncResolvers)e();this._syncResolvers=[]}waitForAgentMapping(e=15e3){return this.agentMapping?Promise.resolve():new Promise(t=>{this._syncResolvers.push(t);const s=setTimeout(()=>{const e=this._syncResolvers.indexOf(t);-1!==e&&this._syncResolvers.splice(e,1),t()},e);"object"==typeof s&&"unref"in s&&s.unref()})}handleTerminalRequest=null;handleTerminalNotification=null;onRunComplete=null;onRunFailed=null;onRunEnded=null;handleSlashCommand=null;onPromptStart=null;instanceId;constructor(e){this.state=e.state,this.transport=e.transport,this.config=e.config,this.logger=e.logger,this.writeObs=e.writeObs,this.getGateway=e.getGateway,this.instanceId=e.instanceId||"unknown",this.thirdPartyChannels=e.thirdPartyChannels??new Set,this.beeosFileResolver=e.beeosFileResolver,this.agentFetch=e.agentFetch,this.agentGatewayUrl=e.agentGatewayUrl,this.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.cronOverrides=createCronOverridesStore(this.logger),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const n=s?.turnId;if(!n)return;const i=this.state.sessions.get(e);i&&recordTurnIdForRole(i,t,n)})}resolvePlatformAgentId(e){return this.agentMapping?.get(e)?.id}getDefaultAgentId(){return this.agentMapping&&this.agentMapping.size>0?this.agentMapping.keys().next().value??"main":"main"}registerSession(e,t={}){const{ensureVerbose:s=!0}=t;this.state.sessions.has(e.sessionId);this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),s&&this.ensureVerboseFull(e),this.subscribeSessionMessages(e)}async ensureVerboseFull(e){if(e.verbosePatched)return;const t=this.getGateway();if(t?.isConnected){e.verbosePatched=!0;try{await t.sessionsPatch(e.gatewaySessionKey,{verboseLevel:"full"})}catch(t){e.verbosePatched=!1,this.logger.debug?.(`[bridge-core] ensureVerboseFull failed: ${t instanceof Error?t.message:t}`)}}}ensureSessionForKey(e){const t=this.state.findSessionByKey(e);if(t)return t;const s=createSessionObject({gatewayKey:e,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels});return this.registerSession(s),this.logger.debug?.(`[bridge-core] ensureSessionForKey: lazily created session sid=${s.sessionId} key=${e}`),s}async preloadSessionsFromGateway(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.sessionsList();if(!t||"object"!=typeof t)return;const s=t.sessions;if(!Array.isArray(s))return;let n=0;for(const e of s){const t=e,s=t.sessionKey??t.key??t.id??t.name;if(!s||"string"!=typeof s)continue;if(this.state.findSessionByKey(s))continue;const i=t.label||t.derivedTitle||t.title||void 0,o=i&&!isPlaceholderTitle(i)?i:void 0,r=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:o});this.registerSession(r,{ensureVerbose:!1}),n++}n>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${n} session(s) from gateway`)}catch(e){this.logger.debug?.("[bridge-core] preloadSessions failed:",e)}else this.logger.debug?.("[bridge-core] preloadSessions skipped: gateway not connected")}async handleRequest(e){this.writeObs({component:"bridge-core",domain:"forward",name:`rpc.request.${e.method}`,severity:"debug",payload:{id:e.id,method:e.method}});const t=e;try{switch(t.method){case"initialize":this.handleInitialize(t);break;case"ping":this.transport.sendResult(t.id,{pong:!0});break;case"session/new":await this.handleSessionNew(t);break;case"session/load":await this.handleSessionLoad(t);break;case"session/list":await this.handleSessionList(t);break;case"session/rename":this.handleSessionRename(t);break;case"session/delete":await this.handleSessionDelete(t);break;case"session/clear":await this.handleSessionClear(t);break;case"session/prompt":await this.handleSessionPrompt(t);break;case"session/set_mode":await this.handleSessionSetMode(t);break;case"session/set_model":await this.handleSessionSetModel(t);break;case"skills/list":await this.handleSkillsList(t);break;case"skills/install":await this.handleSkillsInstall(t);break;case"skills/uninstall":await this.handleSkillsUninstall(t);break;case"skills/update":await this.handleSkillsUpdate(t);break;case"models/list":await this.handleModelsList(t);break;case"session/cancel":this.handleSessionCancel(t.id,t.params);break;case"cron/list":case"cron/status":case"cron/add":case"cron/update":case"cron/remove":case"cron/run":case"cron/runs":await this.handleCronRequest(t);break;default:"mcp/list"===e.method?await this.mcp.handleList(e):"mcp/set"===e.method?await this.mcp.handleSet(e):"mcp/unset"===e.method?await this.mcp.handleUnset(e):e.method.startsWith("terminal/")&&this.handleTerminalRequest?await this.handleTerminalRequest(e):this.transport.sendAcpError(e.id,Errors.MethodNotFound,{message:`Method not found: ${e.method}`})}}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:t instanceof Error?t.message:"Internal error"})}}handleCanvasNotification=null;getCanvasState=null;onSessionLoaded=null;onSkillInstalled=null;onSessionDeleted=null;onSessionCleared=null;handleNotification(e){switch(e.method){case"session/cancel":this.handleSessionCancel(void 0,e.params);break;case"canvas/toggle":case"canvas/clear":this.handleCanvasNotification?this.handleCanvasNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Canvas notification ignored (canvas disabled): ${e.method}`);break;case"canvas/reference":{const t=e.params,s=t?.surfaceId,n=this.state.resolveSessionFromParams(t);if(!n){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}n.referencedSurfaceId=s||void 0,this.logger.debug?.(`[bridge-core] Canvas reference ${s?"set":"cleared"}: ${s??"(none)"}`);break}default:e.method.startsWith("terminal/")&&this.handleTerminalNotification?this.handleTerminalNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Unknown notification: ${e.method}`)}}handleInitialize(e){this.initialized=!0;const t=this.transport.forwardControl;!1===e.params?.forwardThinking&&(t.forwardThinking=!1),!1===e.params?.forwardToolCalls&&(t.forwardToolCalls=!1),this.transport.sendResult(e.id,{protocolVersion:ACP_PROTOCOL_VERSION,agentCapabilities:{loadSession:!0,skills:!0,multipleSessions:!0,sessionCapabilities:{"web-ssh":!!this.config.bridge.shell.enabled,canvas:!!this.config.canvasEnabled},promptCapabilities:{image:!0,audio:!1,embeddedContext:!0},mcp:{}},agentInfo:{name:"beeos-claw",title:"BeeOS Agent Bridge",version:"0.1.0"},_meta:{},authMethods:[]})}async handleSessionNew(e){const t=`agent:${"string"==typeof e.params?.agentId&&e.params.agentId||this.config.gateway.agentId||this.getDefaultAgentId()}:${randomUUID()}`,s="string"==typeof e.params?.cwd?e.params.cwd:".",n=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(n,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:n.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:n.agentId,platformAgentId:n.platformAgentId??null}}),this.fetchHistoryInBackground(t)}fetchHistoryInBackground(e){const t=this.getGateway();t?.isConnected&&loadHistory(t,e,50).catch(e=>{this.logger.debug?.(`[bridge-core] Background history fetch failed: ${e instanceof Error?e.message:e}`)})}async subscribeSessionMessages(e){if(e.messageSubscriptionActive)return;const t=this.getGateway();if(t?.isConnected)try{await t.sessionsMessagesSubscribe(e.gatewaySessionKey),e.messageSubscriptionActive=!0,this.logger.debug?.(`[bridge-core] session.message subscribed sessionKey=${e.gatewaySessionKey}`)}catch(t){this.logger.debug?.(`[bridge-core] session.message subscribe failed key=${e.gatewaySessionKey}: ${t instanceof Error?t.message:t}`)}}async resubscribeAllSessionMessages(){for(const e of this.state.sessions.values())e.messageSubscriptionActive=!1,await this.subscribeSessionMessages(e)}handleSessionMessage(e){const t=e?.sessionKey,s=e?.messageId;if(!t||!s)return;const n=this.state.sessionsByGatewayKey.get(t);if(!n)return;const i=e.message?.role,o="user"===i?"user":"system"===i?"system":"assistant",r=n.lastTurnIdByRole?.[o];if(!r)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${o} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:r};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"message_id_bind",role:o,messageId:s,...l?{timestamp:l}:{}},d),n.lastTurnIdByRole&&(n.lastTurnIdByRole[o]=void 0),"assistant"===o&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,logger:this.logger,writeObs:this.writeObs,isSessionAlive:e=>this.state.sessions.has(e)},{session:n,turnId:r,messageId:s,runId:void 0})}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const n=s.sessions;if(!Array.isArray(n))return;for(const t of n){const s=t,n=s.sessionKey??s.key??s.id??s.name;if(n&&"string"==typeof n&&deriveSessionId(n,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,i=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(i),i}}}catch(e){this.logger.debug?.("[bridge-core] recoverSessionFromGateway failed:",e)}}async resolveOrCreateSession(e){let t=this.state.sessions.get(e);if(!t&&e.startsWith("agent:")&&(t=this.state.findSessionByKey(e)),t||e.startsWith("agent:")||(t=await this.recoverSessionFromGateway(e)),t)return{session:t,created:!1};const s=e.startsWith("agent:"),n=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${n}:${e}`,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels}),this.registerSession(t),{session:t,created:!0}}async handleSessionLoad(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const{session:s}=await this.resolveOrCreateSession(t),n=s.sessionId;this.state.lastActiveSessionId=n,s.chatReplayDedupKeys.clear(),s.sessionLoadInProgress=!0;try{const e=await this.emitHistoryTurns(s);this.emitCurrentStreamSnapshot(s,e)}finally{s.sessionLoadInProgress=!1}this.transport.sendResult(e.id,{sessionId:n}),this.onSessionLoaded?.(n,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})}async emitHistoryTurns(e){const t=new Set,s=e.sessionId;try{const n=this.getGateway(),i=e.assistantMq&&!e.assistantMq.closed,o=i?null:n,r=await loadHistory(o,e.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:e.agentId,sessionId:e.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}}),a=groupIntoTurns(r),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,h=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${r.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${h}`),l>0&&e.pendingUserTurnUpdates&&e.pendingUserTurnUpdates.length>0&&(e.pendingUserTurnUpdates=[]),a.length>0&&isPlaceholderTitle(e.title)&&"manual"!==e.titleSource){const t=a.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const n=stripTransportMetadata(t.content).trim().slice(0,64).replace(/\n/g," ").trim();if(n){e.title=n,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:n}),this.tryGenerateTitleFromHistory(e,s,a)}}}const p=t=>`history:${e.gatewaySessionKey}:${t}`;let g=0,m=-1;if(i)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){m=e;break}for(let n=0;n<a.length;n++){const o=a[n];if(i&&n===m)this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] assistant — mq active, deferring to emitCurrentStreamSnapshot`);else if("user"!==o.role||"internal_system"!==o.provenance?.kind)if("user"===o.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(o.content))this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] system-injected user message`);else if("user"===o.role&&isHeartbeatPromptText(o.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);else if("user"===o.role&&isCronPromptText(o.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] cron-triggered user message sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);else{if("assistant"===o.role){const t=classifyHeartbeatFilter(o.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${n}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${o.content.length} preview="${previewFilteredText(o.content)}"`);continue}}if("system"!==o.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${n}] role=${o.role} contentLen=${o.content.length} content=${JSON.stringify(o.content.slice(0,80))} tcCount=${o.toolCalls?.length??0}`),"user"===o.role)this.replayUserTurnAsChunks(s,o.content,o.metadata,o.timestamp,p(n),o._seq);else{if(await this.replayAssistantTurnAsStream(s,o,p(n)),o.toolCalls)for(const e of o.toolCalls)e.id&&t.add(e.id);this.transport.sendSessionUpdate(s,{sessionUpdate:"stream_complete",stopReason:"end_turn"},{turnId:p(n)}),g+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${n}] contentLen=${o.content.length}`),this.transport.sendSessionUpdate(s,{sessionUpdate:"system_message_chunk",content:{type:"text",text:o.content},metadata:o.metadata,...o.timestamp?{timestamp:o.timestamp}:{}},{turnId:p(n),...void 0!==o._seq?{seq:o._seq}:{}})}else this.logger.debug?.(`[bridge-core] SKIP history_turn[${n}] internal_system user message`)}this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${g} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const n=e.assistantMq;if(n&&!n.closed){const s=n.peek(0);let i=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const n=s[e],o=n.sessionUpdate,r=n.toolCallId;if("tool_call"!==o&&"tool_call_update"!==o||!r||!t.has(r)){if("agent_message_chunk"===o){if(""===(n.content?.text??"").trim()&&e<s.length-1){const n=s[e+1];if(("tool_call"===n.sessionUpdate||"tool_call_update"===n.sessionUpdate)&&n.toolCallId&&t.has(n.toolCallId)){i=e+1;continue}}break}break}i=e+1}const o=resolveTurnId(e),r=o?{turnId:o}:void 0;for(let t=i;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],r)}}async handleSessionList(e){const t=[];for(const[,e]of this.state.sessions)isBackgroundSessionKey(e.gatewaySessionKey)||this.cronIsolatedSessionKeys.has(e.gatewaySessionKey)||t.push({sessionId:e.sessionId,title:e.title||"New chat",cwd:e.cwd,updatedAt:e.lastStreamActivityTs??e.createdAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(e.gatewaySessionKey)});const s=this.getGateway();if(s?.isConnected)try{const e=await s.sessionsList();if(e&&"object"==typeof e&&Array.isArray(e.sessions)){const s=e.sessions;for(const e of s){const s=e,n=s.sessionKey??s.key??s.id??s.name;if(!n||"string"!=typeof n)continue;if(isBackgroundSessionKey(n))continue;if(this.cronIsolatedSessionKeys.has(n))continue;const i=deriveSessionId(n,this.instanceId),o=t.find(e=>e.sessionId===n||e.sessionId===i||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===n),r=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=r&&!isPlaceholderTitle(r)?r:void 0,l=s.model,d=s.modelProvider;if(o){if(a&&isPlaceholderTitle(o.title)){o.title=a;const e=this.state.sessions.get(o.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(o.updatedAt=e),l&&(o.model=l),d&&(o.modelProvider=d)}else{const e=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s.cwd,title:a});this.registerSession(e,{ensureVerbose:!1}),t.push({sessionId:e.sessionId,title:a||"",cwd:s.cwd,updatedAt:s.updatedAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(n),model:l,modelProvider:d})}}}}catch(e){this.logger.debug?.("[bridge-core] sessions.list enrichment failed:",e)}t.sort((e,t)=>(t.updatedAt??0)-(e.updatedAt??0)||e.sessionId.localeCompare(t.sessionId)),this.transport.sendResult(e.id,{sessions:t})}handleSessionRename(e){const t=e.params?.sessionId,s=e.params?.title;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, title"});const n=s.slice(0,64),i=this.state.sessions.get(t);if(!i)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);i.title=n,i.titleSource="manual";const o=i.gatewaySessionKey,r=this.getGateway();r?.isConnected&&r.sessionsPatch(o,{label:n}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:n})}static TITLE_TEMPLATES=[{match:["今日日报","日报"],zh:"今日工作日报",en:"Daily Work Log"},{match:["周报","周报总结","本周周报"],zh:"本周工作周报",en:"Weekly Work Report"},{match:["月报","月度汇报","月度总结"],zh:"本月工作月报",en:"Monthly Work Report"},{match:["工作日志","日志"],zh:"工作日志整理",en:"Work Log Review"},{match:["晨会","今日晨会"],zh:"今日晨会纪要",en:"Daily Standup Notes"},{match:["复盘","今日复盘"],zh:"今日工作复盘",en:"Daily Retrospective"},{match:["新会话","new chat","new conversation"],zh:"新会话",en:"New Conversation"}];static TITLE_SYSTEM_PROMPT='You are a conversation title generator. Your task is to generate a concise, human-readable title summarizing the topic of the user\'s question together with the assistant\'s answer.\n\n### Rules:\n- Length: 3-6 words in English, or 6-20 characters in Chinese (up to 24 if needed to keep all key domain terms intact).\n- Language: Match the user\'s input language exactly. Mixed zh/en input → use the dominant language.\n- Extract the core topic (noun-centric) of the Q&A exchange, not the action verb.\n- Use the assistant\'s answer to disambiguate or enrich the topic when the question alone is vague (e.g. "帮我看下" + an answer about x402 → "x402 支付集成").\n- No punctuation, no quotation marks, no emojis, no markdown.\n- No prefix like "Title:" or "Here is". Output the title only.\n- Avoid generic words: "help", "question", "chat", "problem", "task".\n- If both the question and answer are too vague to summarize, return "新会话" (for zh) or "New Conversation" (for en).\n\n### Input Format:\nThe user message will contain both the question and the assistant\'s answer, delimited like this:\n[User Question]\n...question text...\n\n[Assistant Answer]\n...answer text (may be truncated with "..." in the middle)...\n\n### Output Format:\nRespond ONLY with valid JSON, no other text:\n{"title": "<your title here>"}\n\n### Examples:\nInput:\n[User Question]\n帮我写一个关于BeeOS设备接入的PRD\n\n[Assistant Answer]\n好的,这是 BeeOS 设备接入 PRD 的草稿:1. 目标 ...\nOutput: {"title": "BeeOS 设备接入 PRD"}\n\nInput:\n[User Question]\n帮我看下\n\n[Assistant Answer]\n你说的 x402 支付协议是一个基于 HTTP 402 的链上支付标准 ...\nOutput: {"title": "x402 支付协议解析"}\n\nInput:\n[User Question]\nWhat\'s the best way to integrate x402 payment protocol?\n\n[Assistant Answer]\nYou have three options: 1. Use the official SDK ...\nOutput: {"title": "x402 Payment Integration"}\n\nInput:\n[User Question]\nhi\n\n[Assistant Answer]\nHello! How can I help you today?\nOutput: {"title": "New Conversation"}';matchTitleTemplate(e){const t=e.trim().toLowerCase();for(const s of AcpGatewayBridgeCore.TITLE_TEMPLATES)if(s.match.some(e=>e.toLowerCase()===t))return/[\u4e00-\u9fff]/.test(e)?s.zh:s.en}isGuardMessage(e){const t=e.trim();return[...t].length<3||[...t].every(e=>/[\p{P}\p{S}\s]/u.test(e))}truncateQuestionForTitle(e){return e.length>2e3?e.slice(0,1500):e}truncateAnswerForTitle(e){return e.length<=1e3?e:`${e.slice(0,500)}\n...\n${e.slice(-500)}`}generateTitleFromQA(e,t,s,n){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const i=this.matchTitleTemplate(s);if(i){e.title=i,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:i}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:i})}const o=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),r=process.env.AROUTER_API_KEY||"";if(!o||!r)return void this.logger.info?.(`[bridge-core] title-gen SKIP sid=${t}: no AROUTER env vars`);const a=`[User Question]\n${this.truncateQuestionForTitle(s)}\n\n[Assistant Answer]\n${this.truncateAnswerForTitle(n)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${n.length}`),fetch(`${o}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify({model:"openai/gpt-4o-mini",max_tokens:32,temperature:.3,messages:[{role:"system",content:AcpGatewayBridgeCore.TITLE_SYSTEM_PROMPT},{role:"user",content:a}]})}).then(async s=>{if(!s.ok){const e=await s.text().catch(()=>"");return void this.logger.info?.(`[bridge-core] title-gen FAILED sid=${t} status=${s.status} body=${e.slice(0,300)}`)}const n=await s.json(),i=n.choices?.[0]?.message?.content?.trim();if(!i)return;const o=i.replace(/^```json\s*/,"").replace(/```$/,"").trim();let r;try{r=JSON.parse(o).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${o}`)}if(!r)return;if("manual"===e.titleSource)return void this.logger.debug?.("[bridge-core] title-gen discarded: user already renamed");this.logger.info?.(`[bridge-core] title-gen OK sid=${t} title="${r}"`),e.title=r,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:r})}).catch(e=>{this.logger.info?.(`[bridge-core] title-gen ERROR sid=${t}: ${e instanceof Error?e.message:e}`)})}async tryGenerateTitleFromHistory(e,t,s){if("manual"===e.titleSource||"auto"===e.titleSource)return;const n=this.getGateway();if(!n?.isConnected)return;let i=s;if(!i)try{const t=await loadHistory(n,e.gatewaySessionKey);i=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const o=i.find(e=>{if("user"!==e.role)return!1;if(!e.content?.trim())return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!o)return;const r=i.indexOf(o),a=i.slice(r+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=stripTransportMetadata(o.content).trim(),d=a.content.trim();l&&d&&this.generateTitleFromQA(e,t,l,d)}async handleSessionDelete(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.info?.(`[bridge-core] session/delete sessionId=${t} NOT FOUND in local state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);const n=s.gatewaySessionKey;this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${n} gwConnected=${!!this.getGateway()?.isConnected}`);let i=!1;const o=this.getGateway();if(o?.isConnected&&n)try{await o.sessionsDelete(n),i=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${n}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${n}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await o.sessionsReset(n),i=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${n}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${n}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else n&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${n}`);void 0!==s.pendingPromptId&&this.failPrompt(s,Errors.SessionDeleted.rpcCode,Errors.SessionDeleted.message),this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const r=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),r.add(e));for(const[e,t]of this.state.toolCallIdMap)r.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(n),this.transport.resetSessionUpdateCount(t),i&&this.onSessionDeleted?.(n),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${n} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}async handleSessionClear(e){this.logger.info?.(`[bridge-core] handleSessionClear called, params=${JSON.stringify(e.params)}`);const t=e.params?.sessionId;if(!t)return this.logger.warn?.("[bridge-core] session/clear missing sessionId"),void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.warn?.(`[bridge-core] session/clear sessionId=${t} not found in state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);this.logger.info?.(`[bridge-core] session/clear sessionId=${t} gatewayKey=${s.gatewaySessionKey}`);const n=this.getGateway(),i=n?.isConnected;if(s.promptDone||void 0===s.pendingPromptId&&void 0===s.currentRunId||(this.logger.info?.(`[bridge-core] session/clear: cancelling active run sid=${t} runId=${s.currentRunId}`),i&&(s.currentRunId||s.gatewayRequestId)&&n.agentCancel(s.gatewaySessionKey,{runId:s.currentRunId,requestId:s.gatewayRequestId}),this.endRun(s,{kind:"complete",stopReason:"cancelled"})),i&&s.gatewaySessionKey)try{await n.sessionsReset(s.gatewaySessionKey),this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${s.gatewaySessionKey}`)}catch(e){this.logger.warn?.(`[bridge-core] Gateway sessions.reset FAILED for key=${s.gatewaySessionKey}: ${e instanceof Error?e.message:e}`)}else this.logger.warn?.(`[bridge-core] session/clear skipped gateway reset: connected=${i}, key=${s.gatewaySessionKey}`);s.chatReplayDedupKeys.clear(),this.logger.info?.("[bridge-core] session/clear dedupKeys cleared, calling onSessionCleared"),this.onSessionCleared?.(t,s.gatewaySessionKey),this.transport.sendResult(e.id,{cleared:!0}),this.logger.info?.("[bridge-core] session/clear DONE, sent cleared=true")}async handleSessionSetMode(e){const t=e.params?.sessionId,s=e.params?.mode,n=t?this.state.sessions.get(t):void 0,i=this.getGateway();if(n&&i?.isConnected&&s){const e={};if("verbose"===s||"detailed"===s?e.verboseLevel="full":"concise"!==s&&"default"!==s||(e.verboseLevel="default"),"reasoning"!==s&&"thinking"!==s||(e.reasoningLevel="stream"),Object.keys(e).length>0)try{await i.sessionsPatch(n.gatewaySessionKey,e)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch failed: ${e instanceof Error?e.message:e}`)}}this.transport.sendResult(e.id,{mode:s??"default"})}async handleSessionSetModel(e){const t=e.params?.sessionId,s=e.params?.model,n=t?this.state.sessions.get(t):void 0,i=this.getGateway();let o=s??null,r=null;if(n&&i?.isConnected&&s)try{const e=await i.sessionsPatch(n.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(o=t.model),t?.modelProvider&&(r=t.modelProvider)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch for model failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{model:o,modelProvider:r})}async handleModelsList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("models.list",{});this.transport.sendResult(e.id,s??{models:[]})}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list models: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleSkillsList(e){const t=await this.fetchSimplifiedSkills();this.transport.sendResult(e.id,{skills:t})}async fetchSimplifiedSkills(){const e=this.getGateway();if(!e?.isConnected)return[];try{const t=await e.skillsStatus();if(!t?.skills)return[];const s=t.skills.filter(e=>!1!==e.eligible||!0===e.disabled).sort((e,t)=>e.always&&!t.always?-1:!e.always&&t.always?1:e.name.localeCompare(t.name)).map(e=>({name:e.name,description:e.description,emoji:e.emoji,always:e.always||void 0,bundled:e.bundled||void 0,disabled:e.disabled||void 0,skillKey:e.skillKey||void 0,iconUrl:void 0})),n=this.getSkillsDir();for(const e of s){const t=e.skillKey??e.name;try{const s=await readFile(join(n,t,".beeos-meta.json"),"utf8"),i=JSON.parse(s);i?.iconUrl&&(e.iconUrl=i.iconUrl)}catch{}}return s}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(){const e=await this.fetchSimplifiedSkills();this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:e}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:e.length}})}getSkillsDir(){const e=process.env.OPENCLAW_HOME||join(homedir(),".openclaw");return join(e,"skills")}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,n=t?.files;let i;try{i=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!n?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(this.getSkillsDir(),i);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(n?.length){const e=resolve(o);for(const t of n){const s=join(o,t.path);if(!resolve(s).startsWith(e+sep))throw new Error(`File path escapes skill directory: ${t.path}`);await mkdir(join(s,".."),{recursive:!0});const n=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!n.ok)throw new Error(`Failed to download ${t.path}: ${n.status}`);const i=Buffer.from(await n.arrayBuffer());await writeFile(s,i)}}if(t?.iconUrl)try{await writeFile(join(o,".beeos-meta.json"),JSON.stringify({iconUrl:t.iconUrl}),"utf8")}catch{}this.logger.info?.(`[bridge-core] Installed skill "${i}" via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${i}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Install failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUninstall(e){const t=e.params;let s;try{s=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const n=resolveSkillInstallDir(this.getSkillsDir(),s),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),s);try{let t=!1;try{const e=await lstat(n);if(e.isSymbolicLink()){const e=await readlink(n);await rm(n,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(n,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(i);(e.isDirectory()||e.isSymbolicLink())&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${s}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${s}"`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Uninstall failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUpdate(e){const t=e.params,s=t?.skillKey,n=t?.enabled;if(!s||"boolean"!=typeof n)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const i=this.getGateway();if(i?.isConnected)try{const t=await i.skillsUpdate(s,n);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${n}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:n}),await this.pushSkillsList()}catch(t){this.logger.error?.(`[bridge-core] Failed to update skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.InternalError,{message:"Gateway not connected"})}async handleSessionPrompt(e){const t=e.params?.sessionId,s=e.params?.prompt;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, prompt"});const{session:n,created:i}=await this.resolveOrCreateSession(t);i&&this.onSessionLoaded?.(n.sessionId,n.gatewaySessionKey,n.platformAgentId);const o=n.sessionId;this.state.lastActiveSessionId=o,this.onPromptStart?.();const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);await this.ensureVerboseFull(n);let a=extractPromptBlocks(sanitizePromptPayload(s));if(!n.title||isPlaceholderTitle(n.title)){const e=a.find(e=>"text"===e.type&&e.text);if(e&&"text"in e&&e.text){const t=stripTransportMetadata(e.text).trim();if(this.logger.info?.(`[bridge-core] title-gen sid=${o} existing="${n.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();n.title=e,n.titleSource="truncated",r?.isConnected&&e&&r.sessionsPatch(n.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(o,{sessionUpdate:"title_update",title:e})}}}const l=buildResolutionPlan(a);if(l.refs.length>0)try{const e=await resolveResources(l,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let d;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{d=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const c=toGatewayPrompt(a,{beeosFileResolutionsByUri:d});if("error"in c)return void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:c.error});const{message:h,attachments:p}=c,g=resolveStandaloneSlashCommandName(a,h)??resolveSlashCmd(a,h),m="string"==typeof g;if(g&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(g)}catch(e){return t.logger.warn?.(`[bridge-core] /${g} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(n,a,resolveTurnId(n)),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${g}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(n)}),void this.transport.sendResult(e.id,{stopReason:"end_turn"})}const u="chat.send";let f=m?h:withUserMessagePrefix(h);const y=this.config.canvasEnabled&&!m&&!1!==n.canvasActive&&!n.channelType;if(y){const e=n.promptsSinceCanvasInjection??0;!n.canvasPromptInjected||e>=10?(f=`${CANVAS_TERM_MAPPING}\n\n${f}`,n.canvasPromptInjected=!0,n.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):n.promptsSinceCanvasInjection=e+1}if(y){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(n.gatewaySessionKey);f=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${f}`}const t=n.referencedSurfaceId;t&&(f=`${CANVAS_UPDATE_HINT}\n[IMPORTANT: The user selected canvas surface "${t}" for editing. You MUST call render_ui with id="${t}" to UPDATE this existing surface. Do NOT create a new surface with a different id.]\n${f}`,n.referencedSurfaceId=void 0)}if(!m){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){n.activeMentions=t;f=`${buildMentionInstruction(t)}\n${f}`,this.logger.debug?.(`[bridge-core] delegation mention instruction injected for ${t.length} agent(s)`)}else n.activeMentions=void 0}m&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${o} command=/${g}`),n.promptDone||void 0===n.pendingPromptId&&void 0===n.currentRunId||(this.logger.warn?.(`[bridge-core] handleSessionPrompt: force-ending stale run sid=${o} oldRunId=${n.currentRunId} oldPromptId=${n.pendingPromptId}`),(n.currentRunId||n.gatewayRequestId)&&r.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId}),this.endRun(n,{kind:"complete",stopReason:"cancelled"})),this.state.resetForNewRun(n,o,this.transport),n.pendingPromptId=e.id,n.gatewayMethod=u;const S=resolveTurnId(n);this.emitPromptAsUserSessionUpdates(n,a,S),this.transport.sendSessionUpdate(o,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},S?{turnId:S}:void 0);const w=extractCronJobId(h);w&&(n.cronJobId=w),this.schedulePromptTimeout(n);const I=p.length>0?p.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,b=`req_${randomUUID().replace(/-/g,"")}`,v={sessionKey:n.gatewaySessionKey,message:f,deliver:!1,idempotencyKey:randomUUID()};I?.length&&(v.attachments=I);if(!r.sendFrame({type:"req",id:b,method:u,params:v}))return this.state.clearPromptTimeout(n),n.pendingPromptId=void 0,n.assistantMq?.close(),n.assistantMq=void 0,n.pendingUserTurnUpdates=void 0,void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});n.gatewayRequestId=b,this.state.promptsByGatewayRequestId.set(b,n)}replayUserTurnAsChunks(e,t,s,n,i,o){const r=splitUserReplayBlocks(t),a=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const t of r){let i;if("text"===t.type){if(!t.text)continue;i={type:"text",text:t.text}}else i="resource_link"===t.type?{type:"resource_link",uri:t.uri??"",...t.fileName?{name:t.fileName}:{},...t.mimeType?{mimeType:t.mimeType}:{}}:{type:t.type,...t.mimeType?{mimeType:t.mimeType}:{},...t.fileName?{fileName:t.fileName}:{}};this.transport.sendSessionUpdate(e,{sessionUpdate:"user_message_chunk",content:i,...s?{metadata:s}:{},...n?{timestamp:n}:{}},a)}}async replayAssistantTurnAsStream(e,t,s){const n=e=>{if(s||void 0!==e)return{...s?{turnId:s}:{},...void 0!==e?{seq:e}:{}}},i=n(t._seq);if(!t.sourceMessages?.length){const s={sessionUpdate:"history_turn",role:t.role,content:t.content,reasoning:t.reasoning,toolCalls:t.toolCalls,...t.timestamp?{timestamp:t.timestamp}:{}};return t.metadata&&(s.metadata=t.metadata),void this.transport.sendSessionUpdate(e,s,i)}for(const s of t.sourceMessages){const i=s.updateType??"agent_message_chunk",o=n(s._seq);switch(i){case"agent_message_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{},...s.metadata?{metadata:s.metadata}:{}},o);break;case"agent_thought_chunk":this.transport.sendSessionUpdate(e,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:s.content},...s.timestamp?{timestamp:s.timestamp}:{}},o);break;case"tool_call":{const n=s.toolCallId??"",i=t.toolCalls?.find(e=>e.id===n),r=s.toolName??"tool",a=s.content??"";let l={};if(a)try{l=JSON.parse(a)}catch{}this.transport.sendSessionUpdate(e,buildToolCallPayload({toolCallId:n,toolName:r,args:l,kind:i?.kind,status:i?.status??"in_progress",timestamp:s.timestamp}),o);break}case"tool_call_update":{const n=s.toolCallId??"",i=t.toolCalls?.find(e=>e.id===n),r=s.toolName??i?.name??"tool",a=s.content??i?.output??"";let l=await readEnrichmentCache(n).catch(()=>{});if(!l){const e=extractBeeosFileUrisFromOutput(a);e.length>0&&(l=e.map(e=>({uri:e,name:"file"})))}if(!l&&"beeos_upload_file"===r){const e=await readUploadToolResult(n).catch(()=>{});if(e){const t=buildToolResultSessionContentBlocks(r,e).filter(e=>"resource_link"===e.content?.type&&e.content.uri).map(e=>({uri:e.content.uri,name:e.content.name??"file",...e.content.mimeType?{mimeType:e.content.mimeType}:{}}));t.length>0&&(l=t)}}this.transport.sendSessionUpdate(e,buildToolCallUpdatePayload({toolCallId:n,toolName:r,outputText:a,kind:i?.kind,status:i?.status??"completed",resourceLinks:l,timestamp:s.timestamp}),o);break}}}}emitPromptAsUserSessionUpdates(e,t,s){const n=(new Date).toISOString(),i=s?{turnId:s}:void 0;for(const o of t){const t=this.toUserSessionUpdateContent(o);if(!t)continue;const r={sessionUpdate:"user_message_chunk",content:t,timestamp:n};s&&void 0===r._turnId&&(r._turnId=s),this.transport.sendSessionUpdate(e.sessionId,r,i),e.pendingUserTurnUpdates?.push(r)}}toUserSessionUpdateContent(e){if("text"===e.type){if(!e.text)return;return{type:"text",text:e.text}}if("image"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.name;if(!e.data&&!e.uri)return;return{type:"image",...e.data?{data:e.data}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource_link"===e.type){if(!e.uri)return;return{type:"resource_link",uri:e.uri,...e.title?{title:e.title}:{},...e.name?{name:e.name}:{},...e.mimeType??e.mime_type?{mimeType:e.mimeType??e.mime_type}:{}}}if("file"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.filename??e.name;if(!e.data&&!e.text&&!e.uri)return;return{type:"file",...e.data?{data:e.data}:{},...e.text?{text:e.text}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource"===e.type){const t=e.resource??{},s=t.uri??e.uri,n=t.mimeType??t.mime_type??e.mimeType??e.mime_type,i=t.text??e.text,o=t.data??e.data,r=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!i&&!o)return;return{type:"resource",resource:{...s?{uri:s}:{},...n?{mimeType:n}:{},...i?{text:i}:{},...o?{data:o}:{},...r?{fileName:r}:{}}}}}handleSessionCancel(e,t){const s=t&&"object"==typeof t&&"sessionId"in t?t.sessionId:void 0;if(!s)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.MissingParam,{param:"sessionId"}));const n=this.state.sessions.get(s);if(!n)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const i=this.getGateway(),o=i?.isConnected,r=!(!n.currentRunId&&!n.gatewayRequestId);if(o&&r){i.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId})}this.endRun(n,{kind:"complete",stopReason:"cancelled"})||this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"stream_complete",stopReason:"cancelled"},{turnId:resolveTurnId(n)}),void 0!==e&&this.transport.sendResult(e,{})}endRun(e,t){if(e.promptDone)return this.logger.debug?.(`[bridge-core] endRun skipped (promptDone=true) sessionId=${e.sessionId} outcome=${t.kind}`),!1;if(void 0===e.pendingPromptId&&void 0===e.currentRunId)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;const s=resolveTurnId(e),n=s?{turnId:s}:void 0;e.promptDone=!0;const i=e.currentRunId;if("complete"===t.kind){if(i&&this.onRunComplete){const s=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(i,s,t.stopReason)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:t.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:t.stopReason},n),this.tryGenerateTitleFromHistory(e,e.sessionId)}else i&&this.onRunFailed&&this.onRunFailed(i,t.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,t.code,t.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:t.message},n);return this.cleanupPrompt(e),this.onRunEnded?.(e,t),!0}schedulePromptTimeout(e){let t;if(this.state.clearPromptTimeout(e),e.cronJobId){const s=this.cronOverrides.get(e.cronJobId)??this.config.cronMaxDurationDefaultMs;t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,s))}else t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{void 0===e.pendingPromptId&&void 0===e.currentRunId||((e.currentRunId||e.gatewayRequestId)&&this.getGateway()?.agentCancel(e.gatewaySessionKey,{runId:e.currentRunId,requestId:e.gatewayRequestId}),this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),this.failPrompt(e,Errors.PromptTimeout.rpcCode,`Prompt timed out after ${t}ms`))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.delete(t),this.state.runIdToCronJobId.delete(t),this.state.markRunCompleted(t);const e=`tc_${t}_`;for(const[t,s]of this.state.toolCallIdMap)s.startsWith(e)&&this.state.toolCallIdMap.delete(t)}e.gatewayRequestId&&this.state.promptsByGatewayRequestId.delete(e.gatewayRequestId),e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,e.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}completePrompt(e,t){this.endRun(e,{kind:"complete",stopReason:t})}failPrompt(e,t,s){this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())this.failPrompt(e,Errors.GatewayDisconnected.rpcCode,Errors.GatewayDisconnected.message)}handleGatewayResponse(e){const t=e.id;if(!t)return;const s=this.state.promptsByGatewayRequestId.get(t);if(!s||s.promptDone)return;if(!e.ok){const n=e.error,i=n?.message??"gateway returned error";return this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${i}`),void this.failPrompt(s,Errors.SendFailed.rpcCode,i)}const n=e.payload,i=n?.runId;i?(s.currentRunId=i,s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(i,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${i} gatewayRequestId=${t} sessionId=${s.sessionId}`)):this.logger.debug?.(`[bridge-core] handleGatewayResponse: no runId in payload gatewayRequestId=${t} sessionId=${s.sessionId}`)}maybeCompletePromptFromChatState(e,t){if(e.promptDone||"chat.send"!==e.gatewayMethod)return;const s="string"==typeof t.state?t.state.toLowerCase():void 0;if(s)if("final"===s){const s="string"==typeof t.stopReason?t.stopReason:void 0;this.completePrompt(e,s||"end_turn")}else if("aborted"===s||"cancelled"===s||"cancel"===s)this.completePrompt(e,"cancelled");else if("error"===s){const s=("string"==typeof t.summary?t.summary:void 0)??("string"==typeof t.message?t.message:void 0)??"gateway chat error";this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),n={...e.params??{}},i="cron.update"===s;!i||n.patch&&"object"==typeof n.patch||(n.patch={});const o=i?n.patch:n,r=o._beeos_meta;let a;if(delete o._beeos_meta,"cron.add"!==s&&"cron.update"!==s||!0!==o._beeos_internal)void 0!==o._beeos_internal&&delete o._beeos_internal;else if(delete o._beeos_internal,o.delivery={mode:"none"},"cron.add"===s){const e=await this.allocateCronChannel(o.name??"cron");e&&(o.description=encodeBeeosChannelTag(o.description,e))}else{const e=n.id,t="description"in o,s=o.description;let i,r=decodeBeeosChannelId(s);!r&&e&&(r=this.cronChannelCache.get(e)),!r&&e&&(i=await this.lookupCronJobDescription(e),r=decodeBeeosChannelId(i));let l=!1;r||(r=await this.allocateCronChannel(o.name??"cron"),l=!0),r&&(t?o.description=encodeBeeosChannelTag(s,r):l&&(o.description=encodeBeeosChannelTag(i,r)),a=r)}try{const i=await t.request(s,n),l="cron.list"===s||"cron.status"===s?this.enrichCronResponseWithOverrides(i):i;if(this.transport.sendResult(e.id,l??{}),("cron.add"===s||"cron.update"===s)&&i&&"object"==typeof i){const e=i.id;if(e&&void 0!==r?.maxDurationMs){const t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,r.maxDurationMs));this.cronOverrides.set(e,t)}}if("cron.remove"===s){const t=e.params?.id;t&&(this.cronOverrides.delete(t),this.cronChannelCache.delete(t))}if(("cron.add"===s||"cron.update"===s)&&i&&"object"==typeof i){const e=i.id,t=decodeBeeosChannelId(o.description)??a;e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const n=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${n}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:n})}}enrichCronResponseWithOverrides(e){if(!e||"object"!=typeof e)return e;const t=e,s=e=>{if(!e||"object"!=typeof e)return e;const t=e,s=t.id;if("string"!=typeof s)return e;const n=this.cronOverrides.get(s);return"number"!=typeof n?e:{...t,_beeos_meta:{maxDurationMs:n}}};if(Array.isArray(t.jobs))return{...t,jobs:t.jobs.map(s)};if(t.jobs&&"object"==typeof t.jobs){const e=t.jobs,n={};for(const[t,i]of Object.entries(e))n[t]=s(i);return{...t,jobs:n}}return e}async allocateCronChannel(e){if(this.agentFetch&&this.agentGatewayUrl)try{const t=`${this.agentGatewayUrl}/api/v1/channels`,s=await this.agentFetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({participants:[this.instanceId,"service:cron"],metadata:{source:"cron",job_name:e}})});if(!s.ok)return void this.logger.warn?.(`[bridge-core] channel alloc failed: ${s.status}`);return(await s.json()).id}catch(e){return void this.logger.warn?.(`[bridge-core] channel alloc error: ${e instanceof Error?e.message:e}`)}else this.logger.debug?.("[bridge-core] agentFetch/agentGatewayUrl unavailable, skip channel alloc")}async lookupCronJobDescription(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("cron.list",{}),n=s?.jobs;if(!Array.isArray(n))return;for(const t of n){if(!t||"object"!=typeof t)continue;const s=t;if(s.id===e)return"string"==typeof s.description?s.description:void 0}}catch(e){this.logger.debug?.(`[bridge-core] cron.list description lookup failed: ${e instanceof Error?e.message:e}`)}}setCronChannelCache(e,t){if(this.cronChannelCache.size>=AcpGatewayBridgeCore.CRON_CHANNEL_CACHE_MAX){const e=this.cronChannelCache.keys().next().value;void 0!==e&&this.cronChannelCache.delete(e)}this.cronChannelCache.set(e,t)}async refreshCronIsolatedSessionKeys(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.request("cron.list",{}),s=t?.jobs;if(!Array.isArray(s))return;const n=new Set,i=[];for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("string"==typeof t.id&&i.push(t.id),"isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&n.add(s)}this.cronIsolatedSessionKeys=n,this.cronOverrides.reconcile(i)}catch(e){this.logger.debug?.(`[bridge-core] refreshCronIsolatedSessionKeys failed: ${e instanceof Error?e.message:e}`)}}}async function fetchWithRetry(e,t={}){const s=t.maxRetries??3;let n;for(let i=0;i<=s;i++){const o=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(o.ok)return Buffer.from(await o.arrayBuffer());if(429===o.status||o.status>=500){const r=Number(o.headers.get("retry-after"))||0,a=1e3*Math.max(r,2**i);if(n=new Error(`HTTP ${o.status} from ${e}`),i<s){t.logger?.info?.(`[bridge-core] Download ${o.status}, retry ${i+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${o.status}`)}throw n??new Error("Download failed after retries")}const SKILL_ROOT_MARKERS=["SKILL.md","skill.md","SKILL.MD"];async function resolveSkillRoot(e){for(const t of SKILL_ROOT_MARKERS)try{return await stat(join(e,t)),e}catch{}const t=(await readdir(e,{withFileTypes:!0})).filter(e=>e.isDirectory()&&!e.name.startsWith(".")&&"__MACOSX"!==e.name);if(1===t.length){const s=join(e,t[0].name);for(const e of SKILL_ROOT_MARKERS)try{return await stat(join(s,e)),s}catch{}return s}return e}async function extractZipToDir(e,t){const s=new AdmZip(e),n=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(n,{recursive:!0}),s.extractAllTo(n,!0);const e=await resolveSkillRoot(n),i=await readdir(e,{withFileTypes:!0});let o=0;for(const s of i){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const n=join(e,s.name),i=join(t,s.name);await rename(n,i).catch(async()=>{await cp(n,i,{recursive:!0})}),o++}if(0===o)throw new Error("Zip archive contained no extractable files")}finally{await rm(n,{recursive:!0,force:!0}).catch(()=>{})}}const VALID_SLUG_PATTERN=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,NON_ASCII_PATTERN=/[^\x00-\x7F]/;function validateSkillSlug(e){const t=e.trim();if(!t||t.includes("/")||t.includes("\\")||t.includes(".."))throw new Error(`Invalid skill slug: ${e}`);if(NON_ASCII_PATTERN.test(t)||!VALID_SLUG_PATTERN.test(t))throw new Error(`Invalid skill slug: ${e}`);return t}function safeDirName(e){const t=e.trim();return t?t.replaceAll("/","__").replaceAll("\\","__"):t}function resolveSkillInstallDir(e,t){const s=safeDirName(t),n=join(e,s),i=resolve(e),o=resolve(n),r=relative(i,o);if(!r||".."===r||r.startsWith(`..${sep}`)||path.isAbsolute(r))throw new Error("Invalid skill target path");return n}
@@ -0,0 +1 @@
1
+ export const EXTERNAL_PROTOCOL_TAGS=[":a2a:",":mcp:",":openapi:"];export const isExternalInvocationSessionKey=o=>EXTERNAL_PROTOCOL_TAGS.some(e=>o.includes(e));
@@ -0,0 +1 @@
1
+ import{describe,it,expect}from"vitest";import{EXTERNAL_PROTOCOL_TAGS,isExternalInvocationSessionKey}from"./external-protocol-tags.js";describe("EXTERNAL_PROTOCOL_TAGS",()=>{it("contains exactly the three known chatinvoke tags",()=>{expect([...EXTERNAL_PROTOCOL_TAGS]).toEqual([":a2a:",":mcp:",":openapi:"])})}),describe("isExternalInvocationSessionKey",()=>{it("matches A2A chatinvoke session keys",()=>{expect(isExternalInvocationSessionKey("agent:11111111-2222-3333-4444-555555555555:a2a:disc-1")).toBe(!0)}),it("matches MCP chatinvoke session keys",()=>{expect(isExternalInvocationSessionKey("agent:11111111-2222-3333-4444-555555555555:mcp:disc-2")).toBe(!0)}),it("matches OpenAPI chatinvoke session keys",()=>{expect(isExternalInvocationSessionKey("agent:11111111-2222-3333-4444-555555555555:openapi:disc-3")).toBe(!0)}),it("does NOT match interactive ACP sessions (no chatinvoke tag)",()=>{expect(isExternalInvocationSessionKey("agent:main:main")).toBe(!1),expect(isExternalInvocationSessionKey("agent:11111111-2222-3333-4444-555555555555:web-session-abc")).toBe(!1)}),it("does NOT match cron / hook / node- prefixed claw-internal sessions",()=>{expect(isExternalInvocationSessionKey("agent:bot:cron:job-1")).toBe(!1),expect(isExternalInvocationSessionKey("agent:bot:hook:evt-2")).toBe(!1),expect(isExternalInvocationSessionKey("node-legacy-1")).toBe(!1)}),it("handles empty string",()=>{expect(isExternalInvocationSessionKey("")).toBe(!1)}),it("requires the colon-wrapped tag form (substring of tag alone does not match)",()=>{expect(isExternalInvocationSessionKey("agent:bot:my-a2a-debug")).toBe(!1)})});
@@ -1 +1 @@
1
- import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayEvents}from"./gateway-events.js";import{AcpGatewaySessionState}from"./session-state.js";function makeDeps(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),n=vi.fn().mockReturnValue(!0),s={sendSessionUpdate:n,sendResult:vi.fn(),sendAcpError:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a=vi.fn(),o=vi.fn(),i=vi.fn(),r=vi.fn(),d=vi.fn();return{events:new AcpGatewayEvents({state:t,transport:s,config:{gateway:{agentId:"main"}},logger:e,writeObs:d,completePrompt:a,failPrompt:o,schedulePromptTimeout:vi.fn(),maybeCompletePromptFromChatState:i,onStreamDelta:r}),state:t,transport:s,sendSessionUpdate:n,onStreamDelta:r,logger:e,writeObs:d}}function makeSession(e,t){const n={sessionId:"s1",gatewaySessionKey:"main/s1",agentId:"main",currentRunId:t,pendingPromptId:1,promptDone:!1,createdAt:Date.now(),chatReplayDedupKeys:new Set};return e.sessions.set(n.sessionId,n),e.promptsByRunId.set(t,n),n}describe("Bug-Z: heartbeat live-path filtering",()=>{let e,t;beforeEach(()=>{e=makeDeps(),t=makeSession(e.state,"run-heartbeat-1")}),it("drops agent.assistant delta whose cumulative text is HEARTBEAT_OK",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_OK"}};e.events.handleAgentEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(0),expect(e.onStreamDelta).not.toHaveBeenCalled()}),it("emits a structured SKIP log + obs event with reason + preview on prefix drop",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_"}};e.events.handleAgentEvent(t);const n=e.logger.info.mock.calls.map(e=>String(e[0])),s=n.find(e=>e.includes("SKIP heartbeat assistant-delta"));expect(s,`expected a SKIP log, got info=${JSON.stringify(n)}`).toBeDefined(),expect(s).toContain("reason=prefix"),expect(s).toContain("sessionId=s1"),expect(s).toContain("runId=run-heartbeat-1"),expect(s).toContain('cumulative="HEARTBEAT_"'),expect(s).toContain('delta="HEARTBEAT_"');const a=e.writeObs.mock.calls.map(e=>e[0]).find(e=>"heartbeat_filter_skip"===e.name);expect(a).toBeDefined(),expect(a.component).toBe("gateway-events"),expect(a.severity).toBe("info");const o=a.payload;expect(o.source).toBe("assistant-delta"),expect(o.reason).toBe("prefix"),expect(o.sessionId).toBe("s1"),expect(o.runId).toBe("run-heartbeat-1"),expect(o.cumulativePreview).toBe("HEARTBEAT_"),expect(o.deltaPreview).toBe("HEARTBEAT_")}),it('drops heartbeat ack across split deltas ("HEART" + "BEAT_OK") — no underscore or full token leaks',()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEART"}},n={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:2,ts:Date.now(),data:{delta:"BEAT_OK"}};e.events.handleAgentEvent(t),e.events.handleAgentEvent(n);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate).map(e=>e.content?.text??"").join("");expect(s).not.toContain("_"),expect(s).not.toContain("HEARTBEAT_OK")}),it('drops real-world split ("HEARTBEAT_" + "OK") — the ghost bubble case',()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_"}},n={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:2,ts:Date.now(),data:{delta:"OK"}};e.events.handleAgentEvent(t),e.events.handleAgentEvent(n);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(s).toHaveLength(0),expect(e.onStreamDelta).not.toHaveBeenCalled()}),it('drops fine-grained split ("H" + "EARTBEAT" + "_OK") at least at the underscore boundary',()=>{const t=["H","EARTBEAT","_OK"];for(let n=0;n<t.length;n++)e.events.handleAgentEvent({stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:n+1,ts:Date.now(),data:{delta:t[n]}});const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate).map(e=>e.content?.text??"").join("");expect(n).not.toContain("_"),expect(n).not.toContain("HEARTBEAT_OK")}),it("does NOT drop normal assistant text that happens to contain HEARTBEAT as substring",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"Let's design a HEARTBEAT_OK-like ack for our API."}};e.events.handleAgentEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(1),expect(n[0].content.text).toContain("HEARTBEAT_OK-like")}),it("drops chat.text heartbeat when no agent.assistant stream is active",()=>{const t={runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,message:{role:"assistant",content:[{type:"text",text:"HEARTBEAT_OK"}],timestamp:Date.now()}};e.events.handleChatEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(0)}),it("does NOT drop a real chat.text that only mentions the token inline",()=>{const t={runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,message:{role:"assistant",content:[{type:"text",text:"Use the sentinel HEARTBEAT_OK in your ack handler."}],timestamp:Date.now()}};e.events.handleChatEvent(t);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(1)})});
1
+ import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayEvents}from"./gateway-events.js";import{AcpGatewaySessionState}from"./session-state.js";function makeDeps(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),s=vi.fn().mockReturnValue(!0),n={sendSessionUpdate:s,sendResult:vi.fn(),sendAcpError:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a=vi.fn(),o=vi.fn(),i=vi.fn(),r=vi.fn();return{events:new AcpGatewayEvents({state:t,transport:n,config:{gateway:{agentId:"main"}},logger:e,writeObs:r,completePrompt:a,failPrompt:o,schedulePromptTimeout:vi.fn(),maybeCompletePromptFromChatState:i}),state:t,transport:n,sendSessionUpdate:s,logger:e,writeObs:r}}function makeSession(e,t){const s={sessionId:"s1",gatewaySessionKey:"main/s1",agentId:"main",currentRunId:t,pendingPromptId:1,promptDone:!1,createdAt:Date.now(),chatReplayDedupKeys:new Set};return e.sessions.set(s.sessionId,s),e.promptsByRunId.set(t,s),s}describe("Bug-Z: heartbeat live-path filtering",()=>{let e,t;beforeEach(()=>{e=makeDeps(),t=makeSession(e.state,"run-heartbeat-1")}),it("drops agent.assistant delta whose cumulative text is HEARTBEAT_OK",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_OK"}};e.events.handleAgentEvent(t);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(s).toHaveLength(0)}),it("emits a structured SKIP log + obs event with reason + preview on prefix drop",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_"}};e.events.handleAgentEvent(t);const s=e.logger.info.mock.calls.map(e=>String(e[0])),n=s.find(e=>e.includes("SKIP heartbeat assistant-delta"));expect(n,`expected a SKIP log, got info=${JSON.stringify(s)}`).toBeDefined(),expect(n).toContain("reason=prefix"),expect(n).toContain("sessionId=s1"),expect(n).toContain("runId=run-heartbeat-1"),expect(n).toContain('cumulative="HEARTBEAT_"'),expect(n).toContain('delta="HEARTBEAT_"');const a=e.writeObs.mock.calls.map(e=>e[0]).find(e=>"heartbeat_filter_skip"===e.name);expect(a).toBeDefined(),expect(a.component).toBe("gateway-events"),expect(a.severity).toBe("info");const o=a.payload;expect(o.source).toBe("assistant-delta"),expect(o.reason).toBe("prefix"),expect(o.sessionId).toBe("s1"),expect(o.runId).toBe("run-heartbeat-1"),expect(o.cumulativePreview).toBe("HEARTBEAT_"),expect(o.deltaPreview).toBe("HEARTBEAT_")}),it('drops heartbeat ack across split deltas ("HEART" + "BEAT_OK") — no underscore or full token leaks',()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEART"}},s={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:2,ts:Date.now(),data:{delta:"BEAT_OK"}};e.events.handleAgentEvent(t),e.events.handleAgentEvent(s);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate).map(e=>e.content?.text??"").join("");expect(n).not.toContain("_"),expect(n).not.toContain("HEARTBEAT_OK")}),it('drops real-world split ("HEARTBEAT_" + "OK") — the ghost bubble case',()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"HEARTBEAT_"}},s={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:2,ts:Date.now(),data:{delta:"OK"}};e.events.handleAgentEvent(t),e.events.handleAgentEvent(s);const n=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(n).toHaveLength(0)}),it('drops fine-grained split ("H" + "EARTBEAT" + "_OK") at least at the underscore boundary',()=>{const t=["H","EARTBEAT","_OK"];for(let s=0;s<t.length;s++)e.events.handleAgentEvent({stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:s+1,ts:Date.now(),data:{delta:t[s]}});const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate).map(e=>e.content?.text??"").join("");expect(s).not.toContain("_"),expect(s).not.toContain("HEARTBEAT_OK")}),it("does NOT drop normal assistant text that happens to contain HEARTBEAT as substring",()=>{const t={stream:"assistant",runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,ts:Date.now(),data:{delta:"Let's design a HEARTBEAT_OK-like ack for our API."}};e.events.handleAgentEvent(t);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(s).toHaveLength(1),expect(s[0].content.text).toContain("HEARTBEAT_OK-like")}),it("drops chat.text heartbeat when no agent.assistant stream is active",()=>{const t={runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,message:{role:"assistant",content:[{type:"text",text:"HEARTBEAT_OK"}],timestamp:Date.now()}};e.events.handleChatEvent(t);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(s).toHaveLength(0)}),it("does NOT drop a real chat.text that only mentions the token inline",()=>{const t={runId:"run-heartbeat-1",sessionKey:"main/s1",seq:1,message:{role:"assistant",content:[{type:"text",text:"Use the sentinel HEARTBEAT_OK in your ack handler."}],timestamp:Date.now()}};e.events.handleChatEvent(t);const s=e.sendSessionUpdate.mock.calls.map(e=>e[1]).filter(e=>"agent_message_chunk"===e.sessionUpdate);expect(s).toHaveLength(1)})});
@@ -1 +1 @@
1
- import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{formatFriendlyError}from"../errors/classify.js";import{stripTransportMetadata,normalizeTransportText,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{resolveToolResultBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}function extractResourceLinksFromRawResult(t){if(!isRecord(t)||!Array.isArray(t.files))return;const e=[];for(const s of t.files){if(!isRecord(s))continue;const t=asString(s.uri)??(asString(s.fileId)?`beeos-file://${s.fileId}`:void 0);t&&e.push({uri:t,name:asString(s.name)??"file",...asString(s.mimeType)?{mimeType:asString(s.mimeType)}:{}})}return e.length>0?e:void 0}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const n=roleFromSessionUpdate(e);n&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[n]=s)}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;schedulePromptTimeout;maybeCompletePromptFromChatState;agentFetchFn;onStreamDelta;getMessageClient;getMsgPrincipalId;gatewayRequest;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.schedulePromptTimeout=t.schedulePromptTimeout,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.onStreamDelta=t.onStreamDelta,this.getMessageClient=t.getMessageClient,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.cronChannelCache=t.cronChannelCache}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.completePrompt(t,"end_turn"))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s),this.onStreamDelta?.(t,s)}resolveToolCallId(t,e,s){const n=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(n){const e=this.state.toolCallIdMap.get(n);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(n,n),t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n)}const o=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const n of s.content){const o=n;if(!isRecord(o))continue;const a=asString(o.type);if(!a)continue;const r=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(o.thinking)??normalizeText(o.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const n={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,n)&&(e.assistantMq?.push(n),this.onStreamDelta?.(e,n));continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,o,"start"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=resolveToolArgs(o),a=dedupKey("chat:tool_start",t,JSON.stringify(n));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=buildToolCallPayload({toolCallId:t,toolName:s,args:n,timestamp:r});this.sendTurnUpdate(e,i)&&(e.assistantMq?.push(i),this.onStreamDelta?.(e,i));continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,o,"result"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=o.result,a=normalizeText(o.text)??normalizeText(o.result)??(isRecord(n)||Array.isArray(n)?JSON.stringify(n,null,2):void 0);if(!a)continue;const i=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(i))continue;e.chatReplayDedupKeys.add(i);const l=buildToolCallUpdatePayload({toolCallId:t,toolName:s,outputText:a,rawOutput:n,resourceLinks:extractResourceLinksFromRawResult(n),timestamp:r});this.sendTurnUpdate(e,l)&&(e.assistantMq?.push(l),this.onStreamDelta?.(e,l));continue}if("text"===a&&o.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(o.text);if(!s)continue;const n=classifyHeartbeatFilter(s);if(n.filtered){this.logHeartbeatSkip({source:"chat-text",reason:n.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(i))l=s.slice(i.length);else{if(i.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${i.length}`);const d={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,d),e.assistantMq?.push(d),this.onStreamDelta?.(e,d)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message;this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}t.runId&&e.cronJobId&&!this.state.runIdToCronJobId.has(t.runId)&&this.state.setRunCronJobId(t.runId,e.cronJobId);e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),n=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t),this.onStreamDelta?.(e,t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const o=this.state.extractAgentAssistantDelta(e,t.data??{});if(o){const a=normalizeTransportText(o);if(a){const o=e.agentAssistantTextSoFar??a,r=classifyHeartbeatFilter(o);r.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:r.reason,sessionId:e.sessionId,runId:t.runId,cumulative:o,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),n({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],r=t.data;r&&(Array.isArray(r.content)?a.push(...r.content):isRecord(r.content)?a.push(r.content):"string"==typeof r.type&&a.push(r));for(const t of a){if(!isRecord(t))continue;const o=asString(t.type);if("thinking"===o){e.hasAgentThinkingStream=!0;const n=normalizeText(t.thinking)??normalizeText(t.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&(e.assistantMq?.push(t),this.onStreamDelta?.(e,t))}continue}if("toolCall"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const o=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=resolveToolArgs(t);n(buildToolCallPayload({toolCallId:o,toolName:a,args:r,timestamp:s}));continue}if("toolResult"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const o=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=t.result,i=normalizeText(t.text)??normalizeText(t.result)??(isRecord(r)||Array.isArray(r)?JSON.stringify(r,null,2):void 0);i&&n(buildToolCallUpdatePayload({toolCallId:o,toolName:a,outputText:i,rawOutput:r,resourceLinks:extractResourceLinksFromRawResult(r),timestamp:s}));continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const n=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&(e.assistantMq?.push(t),this.onStreamDelta?.(e,t))}}else if("tool"===t.stream){const o=t.data,a="string"==typeof o?.phase?o.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,o,"start"),a=asString(o?.name)??asString(o?.toolName)??asString(o?.tool_name)??"tool",r=resolveToolArgs(o);n(buildToolCallPayload({toolCallId:t,toolName:a,args:r,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=o?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(o?.name)??asString(o?.toolName)??"tool",r={...o,...t},i=this.resolveToolCallId(e,r,"start"),l=resolveToolArgs(r);n(buildToolCallPayload({toolCallId:i,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,n="string"==typeof s?.phase?s.phase:void 0;if("end"===n)this.completePrompt(e,"end_turn");else if("cancelled"===n||"cancel"===n)this.completePrompt(e,"cancelled");else if("error"===n){const n=s?.error,o=n&&"object"==typeof n?n:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof n?n:void 0)??("string"==typeof o?.message?o.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(o?JSON.stringify(o):void 0)??"gateway lifecycle error",r=formatFriendlyError(a);this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`),this.failPrompt(e,Errors.AgentError.rpcCode,r)}}}async handleToolResult(t,e,s){const n=e.data,o=n?.toolCall,a=("string"==typeof o?.id?o.id:void 0)??("string"==typeof n?.toolCallId?n.toolCallId:void 0),r=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,i=("string"==typeof o?.name?o.name:void 0)??("string"==typeof n?.name?n.name:void 0)??"tool",l="string"==typeof n?.text?n.text:void 0,d=a??r,c=n?.result;let u;try{u=await resolveToolResultBlocks({toolCallId:d,toolName:i,rawText:l,rawResult:c})}catch{u=l?[{type:"content",content:{type:"text",text:l}}]:[]}0===u.length&&l&&(u=[{type:"content",content:{type:"text",text:l}}]);const h=[],g=[];for(const t of u)t.content&&("resource_link"===t.content.type&&t.content.uri?g.push({uri:t.content.uri,name:t.content.name??"file",...t.content.mimeType?{mimeType:t.content.mimeType}:{}}):t.content.text&&h.push(t.content.text));const m=buildToolCallUpdatePayload({toolCallId:r,toolName:i,outputText:h.join("\n"),rawOutput:c,resourceLinks:g,timestamp:s});this.sendTurnUpdate(t,m)&&(t.assistantMq?.push(m),this.onStreamDelta?.(t,m))}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t);const s=this.state.runIdToCronJobId.get(e);s&&(t.cronJobId=s),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue),this.schedulePromptTimeout(t)}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{};"string"==typeof e.runId&&"string"==typeof e.jobId&&this.state.setRunCronJobId(e.runId,e.jobId),this.config.agentGatewayUrl&&this.agentFetchFn&&this.reportCronEventToAgentGateway(e),"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async reportCronEventToAgentGateway(t){if(!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e={...t},s="string"==typeof t.sessionKey?t.sessionKey:void 0;s&&null==e.sessionKey&&(e.sessionKey=s);const n=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(n&&(e.sessionId=n),("string"!=typeof e.name||!e.name)&&"string"==typeof t.jobId&&t.jobId&&this.gatewayRequest)try{const s=await this.gatewayRequest("cron.list",{}),n=s?.jobs;if(Array.isArray(n))for(const s of n)if(isRecord(s)&&s.id===t.jobId&&"string"==typeof s.name&&s.name){e.name=s.name;break}}catch(t){this.logger.debug?.(`[gateway-events] cron.list lookup failed: ${t instanceof Error?t.message:t}`)}const o=`${this.config.agentGatewayUrl}/api/v1/agent/events`;try{await this.agentFetchFn(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:e})})}catch(t){this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)}}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),n=t?.jobs;if(Array.isArray(n))for(const t of n){if(!t||"object"!=typeof t)continue;const n=t;if(n.id===e){s=n;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const n="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(n)){const t=decodeBeeosChannelId(n);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const o=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(o&&"string"==typeof o.kind?o.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const r="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!r)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const i=this.state.findSessionByKey(r),l=i?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${r} job=${e}`);const d=encodeBeeosChannelTag(n,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:d}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageClient?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const n="string"==typeof t.jobId?t.jobId:void 0;if(!n)return;const o=t.summary;let a=this.cronChannelCache?.get(n);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===n){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const r=randomUUID();try{await e.messages.send({conversationId:a,id:r,type:"agent_reply",content:{text:o,cron_job_id:n,session_key:t.sessionKey??void 0}}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${n}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${n} err=${e}`),void this.cronChannelCache?.delete(n);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewFilteredText(t.cumulative),s=void 0!==t.delta?previewFilteredText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
1
+ import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{formatFriendlyError}from"../errors/classify.js";import{stripTransportMetadata,normalizeTransportText,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{resolveToolResultBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}function extractResourceLinksFromRawResult(t){if(!isRecord(t)||!Array.isArray(t.files))return;const e=[];for(const s of t.files){if(!isRecord(s))continue;const t=asString(s.uri)??(asString(s.fileId)?`beeos-file://${s.fileId}`:void 0);t&&e.push({uri:t,name:asString(s.name)??"file",...asString(s.mimeType)?{mimeType:asString(s.mimeType)}:{}})}return e.length>0?e:void 0}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const n=roleFromSessionUpdate(e);n&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[n]=s)}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;schedulePromptTimeout;maybeCompletePromptFromChatState;agentFetchFn;getMessageClient;getMsgPrincipalId;gatewayRequest;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.schedulePromptTimeout=t.schedulePromptTimeout,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.getMessageClient=t.getMessageClient,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.cronChannelCache=t.cronChannelCache}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.completePrompt(t,"timeout"))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s)}resolveToolCallId(t,e,s){const n=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(n){const e=this.state.toolCallIdMap.get(n);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(n,n),t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n)}const o=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const n of s.content){const o=n;if(!isRecord(o))continue;const a=asString(o.type);if(!a)continue;const r=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(o.thinking)??normalizeText(o.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const n={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,n)&&e.assistantMq?.push(n);continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,o,"start"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=resolveToolArgs(o),a=dedupKey("chat:tool_start",t,JSON.stringify(n));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=buildToolCallPayload({toolCallId:t,toolName:s,args:n,timestamp:r});this.sendTurnUpdate(e,i)&&e.assistantMq?.push(i);continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,o,"result"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=o.result,a=normalizeText(o.text)??normalizeText(o.result)??(isRecord(n)||Array.isArray(n)?JSON.stringify(n,null,2):void 0);if(!a)continue;const i=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(i))continue;e.chatReplayDedupKeys.add(i);const l=buildToolCallUpdatePayload({toolCallId:t,toolName:s,outputText:a,rawOutput:n,resourceLinks:extractResourceLinksFromRawResult(n),timestamp:r});this.sendTurnUpdate(e,l)&&e.assistantMq?.push(l);continue}if("text"===a&&o.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(o.text);if(!s)continue;const n=classifyHeartbeatFilter(s);if(n.filtered){this.logHeartbeatSkip({source:"chat-text",reason:n.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(i))l=s.slice(i.length);else{if(i.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${i.length}`);const d={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,d),e.assistantMq?.push(d)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message;this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}t.runId&&e.cronJobId&&!this.state.runIdToCronJobId.has(t.runId)&&this.state.setRunCronJobId(t.runId,e.cronJobId);e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),n=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const o=this.state.extractAgentAssistantDelta(e,t.data??{});if(o){const a=normalizeTransportText(o);if(a){const o=e.agentAssistantTextSoFar??a,r=classifyHeartbeatFilter(o);r.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:r.reason,sessionId:e.sessionId,runId:t.runId,cumulative:o,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),n({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],r=t.data;r&&(Array.isArray(r.content)?a.push(...r.content):isRecord(r.content)?a.push(r.content):"string"==typeof r.type&&a.push(r));for(const t of a){if(!isRecord(t))continue;const o=asString(t.type);if("thinking"===o){e.hasAgentThinkingStream=!0;const n=normalizeText(t.thinking)??normalizeText(t.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}continue}if("toolCall"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const o=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=resolveToolArgs(t);n(buildToolCallPayload({toolCallId:o,toolName:a,args:r,timestamp:s}));continue}if("toolResult"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const o=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=t.result,i=normalizeText(t.text)??normalizeText(t.result)??(isRecord(r)||Array.isArray(r)?JSON.stringify(r,null,2):void 0);i&&n(buildToolCallUpdatePayload({toolCallId:o,toolName:a,outputText:i,rawOutput:r,resourceLinks:extractResourceLinksFromRawResult(r),timestamp:s}));continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const n=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}}else if("tool"===t.stream){const o=t.data,a="string"==typeof o?.phase?o.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,o,"start"),a=asString(o?.name)??asString(o?.toolName)??asString(o?.tool_name)??"tool",r=resolveToolArgs(o);n(buildToolCallPayload({toolCallId:t,toolName:a,args:r,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=o?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(o?.name)??asString(o?.toolName)??"tool",r={...o,...t},i=this.resolveToolCallId(e,r,"start"),l=resolveToolArgs(r);n(buildToolCallPayload({toolCallId:i,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,n="string"==typeof s?.phase?s.phase:void 0;if("end"===n){const t="string"==typeof s?.stopReason?s.stopReason:void 0;this.completePrompt(e,t||"end_turn")}else if("cancelled"===n||"cancel"===n)this.completePrompt(e,"cancelled");else if("error"===n){const n=s?.error,o=n&&"object"==typeof n?n:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof n?n:void 0)??("string"==typeof o?.message?o.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(o?JSON.stringify(o):void 0)??"gateway lifecycle error",r=formatFriendlyError(a);this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`),this.failPrompt(e,Errors.AgentError.rpcCode,r)}}}async handleToolResult(t,e,s){const n=e.data,o=n?.toolCall,a=("string"==typeof o?.id?o.id:void 0)??("string"==typeof n?.toolCallId?n.toolCallId:void 0),r=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,i=("string"==typeof o?.name?o.name:void 0)??("string"==typeof n?.name?n.name:void 0)??"tool",l="string"==typeof n?.text?n.text:void 0,d=a??r,c=n?.result;let u;try{u=await resolveToolResultBlocks({toolCallId:d,toolName:i,rawText:l,rawResult:c})}catch{u=l?[{type:"content",content:{type:"text",text:l}}]:[]}0===u.length&&l&&(u=[{type:"content",content:{type:"text",text:l}}]);const h=[],g=[];for(const t of u)t.content&&("resource_link"===t.content.type&&t.content.uri?g.push({uri:t.content.uri,name:t.content.name??"file",...t.content.mimeType?{mimeType:t.content.mimeType}:{}}):t.content.text&&h.push(t.content.text));const p=buildToolCallUpdatePayload({toolCallId:r,toolName:i,outputText:h.join("\n"),rawOutput:c,resourceLinks:g,timestamp:s});this.sendTurnUpdate(t,p)&&t.assistantMq?.push(p)}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t);const s=this.state.runIdToCronJobId.get(e);s&&(t.cronJobId=s),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue),this.schedulePromptTimeout(t)}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{};"string"==typeof e.runId&&"string"==typeof e.jobId&&this.state.setRunCronJobId(e.runId,e.jobId),this.config.agentGatewayUrl&&this.agentFetchFn&&this.reportCronEventToAgentGateway(e),"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async reportCronEventToAgentGateway(t){if(!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e={...t},s="string"==typeof t.sessionKey?t.sessionKey:void 0;s&&null==e.sessionKey&&(e.sessionKey=s);const n=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(n&&(e.sessionId=n),("string"!=typeof e.name||!e.name)&&"string"==typeof t.jobId&&t.jobId&&this.gatewayRequest)try{const s=await this.gatewayRequest("cron.list",{}),n=s?.jobs;if(Array.isArray(n))for(const s of n)if(isRecord(s)&&s.id===t.jobId&&"string"==typeof s.name&&s.name){e.name=s.name;break}}catch(t){this.logger.debug?.(`[gateway-events] cron.list lookup failed: ${t instanceof Error?t.message:t}`)}const o=`${this.config.agentGatewayUrl}/api/v1/agent/events`;try{await this.agentFetchFn(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:e})})}catch(t){this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)}}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),n=t?.jobs;if(Array.isArray(n))for(const t of n){if(!t||"object"!=typeof t)continue;const n=t;if(n.id===e){s=n;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const n="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(n)){const t=decodeBeeosChannelId(n);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const o=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(o&&"string"==typeof o.kind?o.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const r="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!r)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const i=this.state.findSessionByKey(r),l=i?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${r} job=${e}`);const d=encodeBeeosChannelTag(n,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:d}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageClient?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const n="string"==typeof t.jobId?t.jobId:void 0;if(!n)return;const o=t.summary;let a=this.cronChannelCache?.get(n);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===n){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const r=randomUUID();try{await e.messages.send({conversationId:a,id:r,type:"agent_reply",content:{text:o,cron_job_id:n,session_key:t.sessionKey??void 0}}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${n}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${n} err=${e}`),void this.cronChannelCache?.delete(n);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewFilteredText(t.cumulative),s=void 0!==t.delta?previewFilteredText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
@@ -1 +1 @@
1
- import{MessageQueue}from"../utils/mq.js";const MAX_TOOL_CALL_MAP_ENTRIES=5e3,MAX_COMPLETED_RUN_IDS=200;export class AcpGatewaySessionState{sessions=new Map;promptsByRunId=new Map;promptsByGatewayRequestId=new Map;sessionsByGatewayKey=new Map;toolCallIdMap=new Map;toolCallToSessionMap=new Map;runIdToCronJobId=new Map;nextToolCallSeq=0;completedRunIds=new Set;lastActiveSessionId;sessionGcTimer=null;MAX_IDLE_SESSION_MS=864e5;EXTERNAL_SESSION_IDLE_MS=3e5;SESSION_GC_INTERVAL_MS=3e5;logger;constructor(s){this.logger=s}findSessionByKey(s){return this.sessionsByGatewayKey.get(s)}resolveSessionFromParams(s){const e="string"==typeof s?.sessionId?s.sessionId:void 0;if(e)return this.sessions.get(e)}isCurrentStreamOwner(s,e){return!e||!!s.currentRunId&&e===s.currentRunId}markRunCompleted(s){if(this.completedRunIds.add(s),this.completedRunIds.size>200){const s=this.completedRunIds.values().next().value;s&&this.completedRunIds.delete(s)}}isRunCompleted(s){return this.completedRunIds.has(s)}setRunCronJobId(s,e){if(this.runIdToCronJobId.set(s,e),this.runIdToCronJobId.size>400){const s=this.runIdToCronJobId.keys().next().value;void 0!==s&&this.runIdToCronJobId.delete(s)}}clearPromptTimeout(s){s.promptTimeoutTimer&&(clearTimeout(s.promptTimeoutTimer),s.promptTimeoutTimer=void 0)}clearStreamIdleTimer(s){s.streamIdleTimer&&(clearTimeout(s.streamIdleTimer),s.streamIdleTimer=void 0)}resetForNewRun(s,e,t){this.clearStreamIdleTimer(s),s.currentRunId&&(this.promptsByRunId.delete(s.currentRunId),this.runIdToCronJobId.delete(s.currentRunId)),s.gatewayRequestId&&this.promptsByGatewayRequestId.delete(s.gatewayRequestId),s.currentRunId=void 0,s.gatewayRequestId=void 0,s.assistantMq&&!s.assistantMq.closed&&(this.logger.warn?.(`[session-state] resetForNewRun discarding stale assistantMq sessionId=${e}`),s.assistantMq.close()),s.assistantMq=new MessageQueue,s.pendingUserTurnUpdates=[],s.chatReplayDedupKeys.clear(),s.hasAgentAssistantStream=!1,s.hasAgentThinkingStream=!1,s.hasAgentToolStartStream=!1,s.hasAgentToolResultStream=!1,s.chatAssistantTextSoFar=void 0,s.agentAssistantTextSoFar=void 0,s.cronJobId=void 0,s.promptDone=!1,t.resetSessionUpdateCount(e),this.enforceToolCallMapLimits()}enforceToolCallMapLimits(){if(this.toolCallIdMap.size>5e3){const s=this.toolCallIdMap.size-5e3;let e=0;for(const t of this.toolCallIdMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(this.toolCallIdMap.get(t)),this.toolCallIdMap.delete(t),e++}}if(this.toolCallToSessionMap.size>5e3){const s=this.toolCallToSessionMap.size-5e3;let e=0;for(const t of this.toolCallToSessionMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(t),e++}}}extractAgentAssistantDelta(s,e){const t=(s.chatAssistantTextSoFar??"").length;if(e.delta){const o=s.agentAssistantTextSoFar??"",i=o+e.delta;if(s.agentAssistantTextSoFar=i,t>0&&o.length<t){if(i.length<=t)return;return i.slice(t)}return e.delta}const o=e.text;if(!o)return;const i=s.agentAssistantTextSoFar??"";s.agentAssistantTextSoFar=o;const n=Math.max(i.length,t);if(!(o.length<=n)){if(o.startsWith(i)||0===n)return o.slice(n)||void 0;if(!i.startsWith(o))return o}}startSessionGc(s,e){this.sessionGcTimer=setInterval(()=>this.gcSessions(s,e),this.SESSION_GC_INTERVAL_MS),"object"==typeof this.sessionGcTimer&&"unref"in this.sessionGcTimer&&this.sessionGcTimer.unref()}gcSessions(s,e){const t=Date.now();let o=0;for(const[i,n]of this.sessions){if(void 0!==n.pendingPromptId)continue;const a=n.lastStreamActivityTs??n.createdAt,r=n.gatewaySessionKey;a<t-(r.includes(":a2a:")||r.includes(":mcp:")||r.includes(":openapi:")?this.EXTERNAL_SESSION_IDLE_MS:this.MAX_IDLE_SESSION_MS)&&(s(n),e?.(n),this.sessions.delete(i),this.sessionsByGatewayKey.delete(n.gatewaySessionKey),o++)}const i=new Set(Array.from(this.sessions.values()).map(s=>s.sessionId));for(const[s,e]of this.toolCallToSessionMap)i.has(e)||this.toolCallToSessionMap.delete(s);for(const[s,e]of this.toolCallIdMap)this.toolCallToSessionMap.has(e)||this.toolCallIdMap.delete(s);o>0&&(this.lastActiveSessionId&&!this.sessions.has(this.lastActiveSessionId)&&(this.lastActiveSessionId=void 0),this.logger.debug?.(`[session-state] GC evicted ${o} idle sessions, remaining=${this.sessions.size}`))}destroy(){this.sessionGcTimer&&(clearInterval(this.sessionGcTimer),this.sessionGcTimer=null);for(const s of this.sessions.values())this.clearPromptTimeout(s),this.clearStreamIdleTimer(s),s.assistantMq?.close();this.promptsByRunId.clear(),this.promptsByGatewayRequestId.clear(),this.runIdToCronJobId.clear(),this.completedRunIds.clear(),this.toolCallIdMap.clear(),this.toolCallToSessionMap.clear(),this.sessionsByGatewayKey.clear(),this.sessions.clear(),this.lastActiveSessionId=void 0}}
1
+ import{MessageQueue}from"../utils/mq.js";import{isExternalInvocationSessionKey}from"./external-protocol-tags.js";const MAX_TOOL_CALL_MAP_ENTRIES=5e3,MAX_COMPLETED_RUN_IDS=200;export class AcpGatewaySessionState{sessions=new Map;promptsByRunId=new Map;promptsByGatewayRequestId=new Map;sessionsByGatewayKey=new Map;toolCallIdMap=new Map;toolCallToSessionMap=new Map;runIdToCronJobId=new Map;nextToolCallSeq=0;completedRunIds=new Set;lastActiveSessionId;sessionGcTimer=null;MAX_IDLE_SESSION_MS=864e5;EXTERNAL_SESSION_IDLE_MS=3e5;SESSION_GC_INTERVAL_MS=3e5;logger;constructor(s){this.logger=s}findSessionByKey(s){return this.sessionsByGatewayKey.get(s)}resolveSessionFromParams(s){const e="string"==typeof s?.sessionId?s.sessionId:void 0;if(e)return this.sessions.get(e)}isCurrentStreamOwner(s,e){return!e||!!s.currentRunId&&e===s.currentRunId}markRunCompleted(s){if(this.completedRunIds.add(s),this.completedRunIds.size>200){const s=this.completedRunIds.values().next().value;s&&this.completedRunIds.delete(s)}}isRunCompleted(s){return this.completedRunIds.has(s)}setRunCronJobId(s,e){if(this.runIdToCronJobId.set(s,e),this.runIdToCronJobId.size>400){const s=this.runIdToCronJobId.keys().next().value;void 0!==s&&this.runIdToCronJobId.delete(s)}}clearPromptTimeout(s){s.promptTimeoutTimer&&(clearTimeout(s.promptTimeoutTimer),s.promptTimeoutTimer=void 0)}clearStreamIdleTimer(s){s.streamIdleTimer&&(clearTimeout(s.streamIdleTimer),s.streamIdleTimer=void 0)}resetForNewRun(s,e,t){this.clearStreamIdleTimer(s),s.currentRunId&&(this.promptsByRunId.delete(s.currentRunId),this.runIdToCronJobId.delete(s.currentRunId)),s.gatewayRequestId&&this.promptsByGatewayRequestId.delete(s.gatewayRequestId),s.currentRunId=void 0,s.gatewayRequestId=void 0,s.assistantMq&&!s.assistantMq.closed&&(this.logger.warn?.(`[session-state] resetForNewRun discarding stale assistantMq sessionId=${e}`),s.assistantMq.close()),s.assistantMq=new MessageQueue,s.pendingUserTurnUpdates=[],s.chatReplayDedupKeys.clear(),s.hasAgentAssistantStream=!1,s.hasAgentThinkingStream=!1,s.hasAgentToolStartStream=!1,s.hasAgentToolResultStream=!1,s.chatAssistantTextSoFar=void 0,s.agentAssistantTextSoFar=void 0,s.cronJobId=void 0,s.promptDone=!1,t.resetSessionUpdateCount(e),this.enforceToolCallMapLimits()}enforceToolCallMapLimits(){if(this.toolCallIdMap.size>5e3){const s=this.toolCallIdMap.size-5e3;let e=0;for(const t of this.toolCallIdMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(this.toolCallIdMap.get(t)),this.toolCallIdMap.delete(t),e++}}if(this.toolCallToSessionMap.size>5e3){const s=this.toolCallToSessionMap.size-5e3;let e=0;for(const t of this.toolCallToSessionMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(t),e++}}}extractAgentAssistantDelta(s,e){const t=(s.chatAssistantTextSoFar??"").length;if(e.delta){const o=s.agentAssistantTextSoFar??"",i=o+e.delta;if(s.agentAssistantTextSoFar=i,t>0&&o.length<t){if(i.length<=t)return;return i.slice(t)}return e.delta}const o=e.text;if(!o)return;const i=s.agentAssistantTextSoFar??"";s.agentAssistantTextSoFar=o;const n=Math.max(i.length,t);if(!(o.length<=n)){if(o.startsWith(i)||0===n)return o.slice(n)||void 0;if(!i.startsWith(o))return o}}startSessionGc(s,e){this.sessionGcTimer=setInterval(()=>this.gcSessions(s,e),this.SESSION_GC_INTERVAL_MS),"object"==typeof this.sessionGcTimer&&"unref"in this.sessionGcTimer&&this.sessionGcTimer.unref()}gcSessions(s,e){const t=Date.now();let o=0;for(const[i,n]of this.sessions){if(void 0!==n.pendingPromptId)continue;(n.lastStreamActivityTs??n.createdAt)<t-(isExternalInvocationSessionKey(n.gatewaySessionKey)?this.EXTERNAL_SESSION_IDLE_MS:this.MAX_IDLE_SESSION_MS)&&(s(n),e?.(n),this.sessions.delete(i),this.sessionsByGatewayKey.delete(n.gatewaySessionKey),o++)}const i=new Set(Array.from(this.sessions.values()).map(s=>s.sessionId));for(const[s,e]of this.toolCallToSessionMap)i.has(e)||this.toolCallToSessionMap.delete(s);for(const[s,e]of this.toolCallIdMap)this.toolCallToSessionMap.has(e)||this.toolCallIdMap.delete(s);o>0&&(this.lastActiveSessionId&&!this.sessions.has(this.lastActiveSessionId)&&(this.lastActiveSessionId=void 0),this.logger.debug?.(`[session-state] GC evicted ${o} idle sessions, remaining=${this.sessions.size}`))}destroy(){this.sessionGcTimer&&(clearInterval(this.sessionGcTimer),this.sessionGcTimer=null);for(const s of this.sessions.values())this.clearPromptTimeout(s),this.clearStreamIdleTimer(s),s.assistantMq?.close();this.promptsByRunId.clear(),this.promptsByGatewayRequestId.clear(),this.runIdToCronJobId.clear(),this.completedRunIds.clear(),this.toolCallIdMap.clear(),this.toolCallToSessionMap.clear(),this.sessionsByGatewayKey.clear(),this.sessions.clear(),this.lastActiveSessionId=void 0}}
@@ -1 +1 @@
1
- import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;messageClient=null;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey,t.inputProvenance,t.channelId,t.inboundMessageId).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s,n,i,r){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const a=this.config.gateway.agentId||this.core.getDefaultAgentId();let o,c;if(s?(o=this.state.findSessionByKey(s),c=s,o?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${o.sessionId}`):(o=this.core.ensureSessionForKey(c),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${o.sessionId}`))):(c=`agent:${a}:a2a:${randomUUID()}`,o=this.core.ensureSessionForKey(c),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created A2A session key=${c}`)),i&&o&&(o.channelId=i),r&&o&&(o.inboundMessageId=r),o&&!o.promptDone&&(void 0!==o.pendingPromptId||void 0!==o.currentRunId||void 0!==o.gatewayRequestId))return(o.pendingAgentSends??=[]).push({message:e,attachments:t,inputProvenance:n,channelId:i,inboundMessageId:r}),void this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",o.pendingAgentSends.length);const g=await this.gateway.agentSend(c,e,a,t,n);return g?.runId&&(o?(this.state.resetForNewRun(o,o.sessionId,this.transport),o.currentRunId=g.runId,o.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(g.runId,o)):this.state.promptsByRunId.set(g.runId,{gatewaySessionKey:c,channelId:i,inboundMessageId:r})),g}setMessageClient(e,t){this.messageClient=e,this.msgPrincipalId=t}async publishAgentReply(e,t,s,n){const i=this.messageClient,r=this.msgPrincipalId;if(i&&r){if(e)try{return await i.messages.send({conversationId:e,type:s,content:n,replyTo:t}),void this.logger.info?.(`[acp-server] ${s} persisted channel=${e} in_reply_to=${t??"-"}`)}catch(t){this.logger.warn?.(`[acp-server] messages.send failed channel=${e}: ${t instanceof Error?t.message:String(t)}; falling back to personal channel`)}await i.identities.send({identityId:r,type:s,content:n})}}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger);const a=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:a,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache,onStreamDelta:(e,t)=>{if(!this.messageClient||!this.msgPrincipalId)return;const s=e.gatewaySessionKey;if(!s)return;const n=e.channelId,i={session_key:s,update:t};(n?this.messageClient.messages.send({conversationId:n,type:"agent_reply_delta",content:i}):this.messageClient.identities.send({identityId:this.msgPrincipalId,type:"agent_reply_delta",content:i})).catch(e=>this.logger.debug?.(`[acp-server] agent_reply_delta publish failed: ${e}`))}}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.onRunComplete=(e,t)=>{const s=this.state.promptsByRunId.get(e),n=s?.gatewaySessionKey,i=s?.channelId,r=s?.inboundMessageId;if(this.logger.info?.(`[acp-server] onRunComplete runId=${e} session_key=${n} channel=${i} in_reply_to=${r??"-"} principal=${this.msgPrincipalId} hasClient=${!!this.messageClient}`),!n)return void this.logger.warn?.(`[acp-server] onRunComplete: no session_key for runId=${e}, cannot publish reply`);const a={session_key:n,text:t};this.publishAgentReply(i,r,"agent_reply",a).catch(e=>this.logger.warn?.(`[acp-server] agent_reply publish failed: ${e instanceof Error?e.message:JSON.stringify(e)}`))},this.core.onRunFailed=(e,t)=>{const s=this.state.promptsByRunId.get(e),n=s?.gatewaySessionKey,i=s?.channelId,r=s?.inboundMessageId;if(this.logger.info?.(`[acp-server] onRunFailed runId=${e} session_key=${n} channel=${i} in_reply_to=${r??"-"}`),!n)return;const a={session_key:n,error:t||"execution failed"};this.publishAgentReply(i,r,"agent_reply",a).catch(e=>this.logger.warn?.(`[acp-server] agent_reply error publish failed: ${e instanceof Error?e.message:String(e)}`))},this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0;const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();if(await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`),s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=()=>{if(e+1>=6)return void this.logger.warn?.("[acp-server] agent sync exhausted 6 attempts — waiting for Gateway reconnect");const t=e<2?1e3*(e+1):Math.min(5e3*2**(e-2),12e4);this.logger.info?.(`[acp-server] agent sync retry #${e+1} in ${Math.round(t/1e3)}s`),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(e+1)},t)},s=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-s}ms)`)):t()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),t()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}pushToolResultContent(e,t,s,n){this.core.pushToolResultContent(e,t,s,n)}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
1
+ import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;if(s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`)),i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),void this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length);const a=await this.gateway.agentSend(r,e,n,t);return a?.runId&&i&&(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=a.runId,i.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(a.runId,i)),a}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const a=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:a,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0;const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();if(await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`),s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
@@ -0,0 +1 @@
1
+ export function parseMentionsParam(e){return Array.isArray(e)?e.filter(e=>"object"==typeof e&&null!==e&&"string"==typeof e.agentId&&"string"==typeof e.name):[]}export function buildMentionInstruction(e){if(!e.length)return"";return["[Agent Delegation Request]","The user explicitly @mentioned the following agent(s). You MUST delegate to them using beeos_call_agent.",...e.map(e=>`- Agent "${e.name}" → agentId: "${e.agentId}"`),"IMPORTANT: Always pass the exact agentId (UUID) shown above, NOT the agent name.","Pass the user's message (excluding the @mention text) as the task message.",""].join("\n")}export function resolveAgentIdFromMentions(e,t){if(!e.length)return;const n=t.toLowerCase(),a=e.find(e=>e.name.toLowerCase()===n);return a?.agentId}
@@ -0,0 +1 @@
1
+ import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";const MAX_PROTOCOL_MARKER_LEN=64,DEFAULT_ORIGIN="im",SEEN_MESSAGE_IDS_MAX=256,resolveOrigin=e=>"string"!=typeof e||0===e.length||e.length>64?"im":e;export const createImInboundHandler=e=>{const{runtime:s,pluginConfig:n,client:a,localServer:t,promptRunStore:o,logger:r,writeObs:i,principalIdOverride:c,promptTimeoutMs:d,dispatch:l=dispatchImInboundPrompt}=e,m=new Map;return{onChatMessage:async e=>{const g=extractChatPrompt(e,{source:"chat_message",localAgentId:t.getLocalAgentId()});if(!g)return;if(!g.channelId||!g.messageId)return void r.warn?.(`[beeos-claw] chat_message missing channelId/messageId (sessionKey=${g.sessionKey}) — drop`);if(m.has(g.messageId))return void r.info?.(`[beeos-claw] duplicate chat_message replay dropped (message_id=${g.messageId}, channelId=${g.channelId})`);if(m.size>=256){const e=m.keys().next().value;void 0!==e&&m.delete(e)}m.set(g.messageId,!0);const p=c||t.getDefaultPlatformAgent()?.instanceId||"";if(!p)return void r.warn?.(`[beeos-claw] chat_message dropped (no principalId resolved, sessionKey=${g.sessionKey})`);t.ensureImInboundSession(g.sessionKey,g.channelId,g.messageId);const h=(e.content??{}).metadata??void 0,I=resolveOrigin(h?.protocol);r.debug?.(`[beeos-claw] chat_message dispatch (sessionKey=${g.sessionKey}, channelId=${g.channelId}, origin=${I})`);const u=e=>{i({component:"plugin",domain:"im_dispatch",name:`im.dispatch.${e.type}`,severity:"dispatch_error"===e.type?"warn":"info",payload:e})};try{await l({runtime:s,pluginConfig:n,prompt:{channelId:g.channelId,inboundMessageId:g.messageId,selfPrincipalId:p,dispatchInput:{message:g.message,attachments:g.files},sessionKey:g.sessionKey},client:a,logger:r,promptRunStore:o,origin:I,promptTimeoutMs:d,onLifecycleEvent:u})}catch(e){r.error?.(`[beeos-claw] chat_message dispatch failed (channelId=${g.channelId}, in_reply_to=${g.messageId}): ${e instanceof Error?e.message:String(e)}`)}},onChatCancel:e=>{const s=e.content??{},n="string"==typeof s.target_message_id?s.target_message_id:"",a="string"==typeof s.reason?s.reason:"unknown";if(!n)return void r.warn?.(`[beeos-claw] chat_cancel dropped (no target_message_id, reason=${a})`);const t=o.abort(n,`chat_cancel:${a}`);t.ok?r.info?.(`[beeos-claw] chat_cancel aborted run (target_message_id=${n}, requestId=${t.requestId}, channelId=${t.channelId}, reason=${a})`):r.info?.(`[beeos-claw] chat_cancel no-op (target_message_id=${n}, reason=${a}, why=${t.reason})`)}}};
@@ -0,0 +1 @@
1
+ import{describe,it,expect,beforeEach,vi}from"vitest";import{createImInboundHandler}from"./im-inbound-handler.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";const fakeRuntime={channel:{reply:{dispatchReplyFromConfig:async()=>({counts:{tool:0,block:0,final:0}})}}},buildChatMessage=(e={})=>{const t=e.channelId??"chan_test";return{id:e.messageId??"msg_test",type:"chat_message",sender:e.sender??"user_caller",content:{text:e.text??"hello agent",message:e.text??"hello agent",metadata:e.protocol?{protocol:e.protocol,context_id:e.contextId??"ctx_test"}:{context_id:e.contextId??"ctx_test"},channel_id:t}}},buildChatCancel=(e={})=>{const t={reason:e.reason??"canceled"};return e.omitTarget||(t.target_message_id=e.targetMessageId??"msg_target"),{id:"cancel_evt",type:"chat_cancel",sender:"user_caller",content:t}},makeLogger=()=>{const e=[],t=[],a=[],n=[];return{warns:e,errors:t,infos:a,debugs:n,warn:t=>{e.push(t)},error:e=>{t.push(e)},info:e=>{a.push(e)},debug:e=>{n.push(e)}}},makeLocalServer=(e={})=>{const t=[];return{calls:t,getLocalAgentId:()=>e.localAgentId??"agent_local",getDefaultPlatformAgent:()=>e.noDefaultAgent?void 0:{instanceId:e.defaultInstanceId??"instance_self"},ensureImInboundSession:(e,a,n)=>{t.push({sessionKey:e,channelId:a,inboundMessageId:n})}}},fakeClient={};describe("createImInboundHandler",()=>{let e,t,a;beforeEach(()=>{e=new PromptRunStore,t=[],a=async e=>(t.push(e),!0)}),it("happy path: dispatches with correct prompt + origin=im + stamps cron-backfill session BEFORE dispatch",async()=>{const n=makeLocalServer(),r=makeLogger(),o=vi.fn(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:o,dispatch:a}),i=buildChatMessage({channelId:"chan_h1",messageId:"msg_h1"});await s.onChatMessage(i),expect(t).toHaveLength(1);const c=t[0];expect(c.prompt.channelId).toBe("chan_h1"),expect(c.prompt.inboundMessageId).toBe("msg_h1"),expect(c.prompt.selfPrincipalId).toBe("instance_self"),expect(c.prompt.dispatchInput.message).toBe("hello agent"),expect(c.origin).toBe("im"),expect(c.runtime).toBe(fakeRuntime),expect(c.client).toBe(fakeClient),expect(c.promptRunStore).toBe(e),expect(n.calls).toHaveLength(1);const l=n.calls[0];expect(l.channelId).toBe("chan_h1"),expect(l.inboundMessageId).toBe("msg_h1")}),it.each([["a2a","a2a"],["mcp","mcp"],["invoke","invoke"],["im","im"],["webhook","webhook"],["slack-bridge","slack-bridge"],["arbitrary-future-tag","arbitrary-future-tag"]])("origin pass-through: metadata.protocol=%s → origin=%s",async(n,r)=>{const o=makeLocalServer(),s=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:o,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await s.onChatMessage(buildChatMessage({protocol:n})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(r)}),it('origin fallback: missing metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage(buildChatMessage({})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: empty metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_empty",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:"",context_id:"ctx"},channel_id:"chan_empty"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: overlong metadata.protocol (>64 chars) → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="x".repeat(65);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it('origin fallback: non-string metadata.protocol → origin="im"',async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await r.onChatMessage({id:"msg_nonstr",type:"chat_message",sender:"user_caller",content:{text:"hi",message:"hi",metadata:{protocol:42,context_id:"ctx"},channel_id:"chan_nonstr"}}),expect(t).toHaveLength(1),expect(t[0].origin).toBe("im")}),it("origin boundary: exactly 64-char metadata.protocol passes through",async()=>{const n=makeLocalServer(),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a}),o="y".repeat(64);await r.onChatMessage(buildChatMessage({protocol:o})),expect(t).toHaveLength(1),expect(t[0].origin).toBe(o)}),it("drops chat_message when channelId is missing (cron-backfill marker not set)",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage({id:"msg_x",type:"chat_message",sender:"user",content:{text:"hi",message:"hi",metadata:{context_id:"ctx"}}}),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("missing channelId/messageId"))).toBe(!0)}),it("drops chat_message when no principalId resolvable",async()=>{const n=makeLocalServer({noDefaultAgent:!0}),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a});await o.onChatMessage(buildChatMessage()),expect(t).toHaveLength(0),expect(n.calls).toHaveLength(0),expect(r.warns.some(e=>e.includes("no principalId resolved"))).toBe(!0)}),it("principalIdOverride wins over getDefaultPlatformAgent",async()=>{const n=makeLocalServer({defaultInstanceId:"should_not_use"}),r=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),principalIdOverride:"pinned_principal",dispatch:a});await r.onChatMessage(buildChatMessage()),expect(t[0].prompt.selfPrincipalId).toBe("pinned_principal")}),it("dispatch error is swallowed + logged without crashing the handler",async()=>{const t=makeLocalServer(),a=makeLogger(),n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:a,writeObs:vi.fn(),dispatch:async()=>{throw new Error("runtime exploded")}});await expect(n.onChatMessage(buildChatMessage())).resolves.toBeUndefined(),expect(a.errors.some(e=>e.includes("dispatch failed")&&e.includes("runtime exploded"))).toBe(!0)}),it("chat_cancel: target_message_id maps to PromptRunStore.abort with reason prefix",()=>{const t=makeLocalServer(),n=makeLogger(),r=new AbortController;e.register("msg_cancel_me",{requestId:"req_xyz",channelId:"chan_xyz",abortController:r,startedAt:Date.now()});createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:t,promptRunStore:e,logger:n,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({targetMessageId:"msg_cancel_me",reason:"timeout"})),expect(r.signal.aborted).toBe(!0),expect(r.signal.reason).toBe("chat_cancel:timeout"),expect(n.infos.some(e=>e.includes("chat_cancel aborted run")&&e.includes("req_xyz"))).toBe(!0)}),it("chat_cancel: drops when target_message_id missing",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({omitTarget:!0})),expect(t.warns.some(e=>e.includes("chat_cancel dropped"))).toBe(!0)}),it("chat_cancel: no-op when no matching run is registered",()=>{const t=makeLogger();createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:a}).onChatCancel(buildChatCancel({targetMessageId:"msg_unknown"})),expect(t.infos.some(e=>e.includes("chat_cancel no-op")&&e.includes("not_found"))).toBe(!0)}),it("replay dedup: same message_id delivered twice dispatches only once",async()=>{const n=makeLocalServer(),r=makeLogger(),o=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:n,promptRunStore:e,logger:r,writeObs:vi.fn(),dispatch:a}),s=buildChatMessage({channelId:"chan_dup",messageId:"msg_dup"});await o.onChatMessage(s),await o.onChatMessage(s),expect(t).toHaveLength(1),expect(r.infos.some(e=>e.includes("duplicate chat_message replay dropped")&&e.includes("msg_dup"))).toBe(!0)}),it("replay dedup: distinct message_ids both dispatch",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_a"})),await n.onChatMessage(buildChatMessage({messageId:"msg_b"})),expect(t).toHaveLength(2)}),it("replay dedup: a replay after a FAILED dispatch is also dropped (stream already terminal-written by the dispatch catch path)",async()=>{const t=makeLogger();let a=0;const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:t,writeObs:vi.fn(),dispatch:async()=>{throw a+=1,new Error("runtime exploded")}}),r=buildChatMessage({messageId:"msg_fail_replay"});await n.onChatMessage(r),await n.onChatMessage(r),expect(a).toBe(1),expect(t.infos.some(e=>e.includes("duplicate chat_message replay dropped"))).toBe(!0)}),it("replay dedup: LRU evicts oldest beyond 256 entries (insertion order)",async()=>{const n=createImInboundHandler({runtime:fakeRuntime,client:fakeClient,localServer:makeLocalServer(),promptRunStore:e,logger:makeLogger(),writeObs:vi.fn(),dispatch:a});await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"}));for(let e=1;e<=256;e++)await n.onChatMessage(buildChatMessage({messageId:`msg_evict_${e}`}));await n.onChatMessage(buildChatMessage({messageId:"msg_evict_0"})),expect(t).toHaveLength(258)})});
@@ -0,0 +1 @@
1
+ import{randomUUID}from"node:crypto";import{dispatchRuntimeReplyWithRoute,hasRuntimeReplyApi}from"./runtime/reply-router.js";import{createImReplySink}from"./runtime/im-reply-sink.js";import{SerializedCallbackQueue}from"./runtime/callback-queue.js";const normalizePromptTimeoutMs=e=>{if("number"!=typeof e||!Number.isFinite(e))return;const n=Math.max(0,Math.trunc(e));return n>0?n:void 0},formatErrMessage=e=>{if(e instanceof Error)return e.message;if("string"==typeof e)return e;try{return JSON.stringify(e)}catch{return String(e)}};export const dispatchImInboundPrompt=async e=>{const{runtime:n,prompt:a,client:t,logger:r,promptRunStore:i}=e;if(!hasRuntimeReplyApi(n))return r.warn?.(`[im] runtime dispatch skipped reason=reply_api_unavailable channel=${a.channelId} in_reply_to=${a.inboundMessageId}`),e.onLifecycleEvent?.({type:"skipped",reason:"reply_api_unavailable",channelId:a.channelId,inboundMessageId:a.inboundMessageId}),!1;const s=`im_req_${randomUUID().replace(/-/g,"")}`,o=Date.now(),d=e.origin??"im",l={protocol:"im",requestId:s,sessionId:a.sessionKey??"agent:main",sourceMessageId:a.inboundMessageId,chatId:a.channelId,origin:d},c=normalizePromptTimeoutMs(e.promptTimeoutMs),u=new AbortController;if(!i.register(a.inboundMessageId,{requestId:s,channelId:a.channelId,abortController:u,startedAt:o}))return r.warn?.(`[im] runtime dispatch rejected duplicate inboundMessageId=${a.inboundMessageId} requestId=${s} channel=${a.channelId}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:a.channelId,inboundMessageId:a.inboundMessageId,error:"duplicate_inbound_message_id",cause:"abort"}),!1;const m=t.messages.startStream({conversationId:a.channelId,id:randomUUID(),replyTo:e.inReplyTo??a.inboundMessageId,type:"agent_reply"},{onError:(e,n)=>{const t="terminal"===n?r.warn:r.debug;t?.(`[im] runtime stream ${n} PATCH error requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId}: ${e.message}`)}},{sender:a.selfPrincipalId});let p;const I=new SerializedCallbackQueue,g=createImReplySink({stream:m,openReasoningStream:()=>(p=t.messages.startStream({conversationId:a.channelId,id:randomUUID(),replyTo:e.inReplyTo??a.inboundMessageId,type:"agent_reasoning"},{onError:(e,n)=>{const t="terminal"===n?r.warn:r.debug;t?.(`[im] runtime reasoning stream ${n} PATCH error requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId}: ${e.message}`)}},{sender:a.selfPrincipalId}),p),abortSignal:u.signal,callbackQueue:I,logger:r,requestId:s,channelId:a.channelId,inboundMessageId:a.inboundMessageId,origin:d}),h=async(e,n)=>{if(!p)return!1;try{return"cancel"===e?await p.cancel({stopReason:"user_stop"}):"fail"===e?await p.fail(n?{stopReason:n}:void 0):await p.finalize({stopReason:"end_turn"}),!1}catch(e){return r.warn?.(`[im] runtime reasoning stream terminal write failed requestId=${s} channel=${a.channelId}: ${formatErrMessage(e)}`),!0}};r.info?.(`[im] runtime dispatching requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId} origin=${d}`),e.onLifecycleEvent?.({type:"dispatch_start",requestId:s,channelId:a.channelId,inboundMessageId:a.inboundMessageId});let y,f,b=!1;const $=async()=>{g.dispatcher.markComplete();try{await g.dispatcher.waitForIdle()}catch{}};try{const t=dispatchRuntimeReplyWithRoute({runtime:n,pluginConfig:e.pluginConfig,preparedPrompt:a.dispatchInput,replyRoute:l,replySinks:{im:g},onDispatchInvoked:()=>{b=!0}}),i=void 0!==c?Promise.race([t,new Promise((e,n)=>{y=setTimeout(()=>{f=new Error(`IM runtime dispatch timed out after ${c}ms`),r.warn?.(`[im] runtime dispatch timeout requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId} timeout_ms=${c}`),u.abort(f),n(f)},c)})]):t,d=await i.finally(()=>{y&&(clearTimeout(y),y=void 0)});await $();const p=await h("complete");let I=!1;try{await m.finalize({stopReason:"end_turn"})}catch(e){I=!0,r.warn?.(`[im] runtime reply stream finalize failed requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId}: ${formatErrMessage(e)}`)}return r.info?.(`[im] runtime dispatch completed requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId} counts=${JSON.stringify(d.counts)} elapsed_ms=${Date.now()-o}`),e.onLifecycleEvent?.({type:"dispatch_complete",requestId:s,channelId:a.channelId,inboundMessageId:a.inboundMessageId,counts:d.counts,...I?{terminalWriteFailed:!0}:{},...p?{reasoningTerminalWriteFailed:!0}:{}}),!0}catch(n){await $();const t=u.signal.aborted,i=t?u.signal.reason:void 0,o=t&&"string"==typeof i&&i.startsWith("chat_cancel"),d=void 0!==f;let l="runtime_error";d?l="timeout":t&&(l="abort");const c=formatErrMessage(n);o?await h("cancel"):d?await h("fail","timeout"):await h("fail");try{o?await m.cancel({stopReason:"user_stop"}):d?await m.fail({body:c,stopReason:"timeout"}):await m.fail({body:c||"execution failed"})}catch(e){r.warn?.(`[im] runtime stream terminal write failed requestId=${s} channel=${a.channelId}: ${formatErrMessage(e)}`)}throw r.warn?.(`[im] runtime dispatch failed requestId=${s} channel=${a.channelId} in_reply_to=${a.inboundMessageId} cause=${l} dispatchInvoked=${b} error=${c}`),e.onLifecycleEvent?.({type:"dispatch_error",requestId:s,channelId:a.channelId,inboundMessageId:a.inboundMessageId,error:c,cause:l}),n}finally{i.unregister(a.inboundMessageId)}};
@@ -0,0 +1 @@
1
+ import{describe,it,expect,beforeEach}from"vitest";import{dispatchImInboundPrompt}from"./im-runtime-dispatch.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";class FakeMessageStream{body="";isTerminated=!1;thinking=[];failNextTerminal=!1;finalized=null;appendBody(e){this.body+=e}setBody(e){this.body=e}addPart(){}replacePart(){}appendThinking(e,t){this.thinking.push({text:e,state:t})}appendToolUse(){}appendToolResult(){}maybeThrowTerminal(){if(this.failNextTerminal)throw this.failNextTerminal=!1,new Error("terminal PATCH failed")}async finalize(e){return this.maybeThrowTerminal(),this.finalized={state:"completed",stopReason:e?.stopReason??"end_turn",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"completed"}}async fail(e){return this.finalized={state:"failed",stopReason:e?.stopReason??"error",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"failed"}}async cancel(e){return this.finalized={state:"cancelled",stopReason:e?.stopReason??"user_stop",body:this.body},this.isTerminated=!0,{id:"env_fake",state:"cancelled"}}async refuse(e){return this.finalized={state:"refused",stopReason:e?.stopReason??"refused",body:e?.body??this.body},this.isTerminated=!0,{id:"env_fake",state:"refused"}}}class FakeClient{startStreamCalls=[];streams=[];messages={startStream:e=>{this.startStreamCalls.push({conversationId:e.conversationId,id:e.id??"",replyTo:e.replyTo??"",type:e.type});const t=new FakeMessageStream;return this.streams.push(t),t}}}const makeRuntime=e=>({channel:{reply:{dispatchReplyFromConfig:async t=>{const a=t.replyOptions?.abortSignal;if(!a)throw new Error("expected abortSignal on replyOptions");return e({dispatcher:t.dispatcher,replyOptions:t.replyOptions,abortSignal:a})}}}}),stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},baseArgs=(e,t,a,s,n={})=>({runtime:e,prompt:{channelId:"ch_t1",inboundMessageId:"msg_t1",selfPrincipalId:"agent_self_t1",dispatchInput:{message:"hello agent"},...n.prompt??{}},client:t,logger:stubLogger(),promptRunStore:a,origin:"im",onLifecycleEvent:e=>s.push(e),...n});describe("dispatchImInboundPrompt",()=>{let e,t,a;beforeEach(()=>{e=new FakeClient,t=new PromptRunStore,a=[]}),it("happy path: emits dispatch_start + dispatch_complete and finalizes the stream",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onPartialReply({text:"Hi"}),t.onPartialReply({text:"Hi there"}),e.sendFinalReply({text:"Hi there!"}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1);const o=e.streams[0];expect(o.body).toBe("Hi there!"),expect(o.finalized).toEqual({state:"completed",stopReason:"end_turn",body:"Hi there!"}),expect(a.map(e=>e.type)).toEqual(["dispatch_start","dispatch_complete"]),expect(t.size()).toBe(0)}),it("skip path: returns false when runtime exposes no dispatchReplyFromConfig",async()=>{const s=await dispatchImInboundPrompt(baseArgs({},e,t,a));expect(s).toBe(!1),expect(e.streams).toHaveLength(0),expect(a).toEqual([{type:"skipped",reason:"reply_api_unavailable",channelId:"ch_t1",inboundMessageId:"msg_t1"}])}),it("timeout path: AbortController fires, stream is failed with stopReason=timeout",async()=>{const s=makeRuntime(async({abortSignal:e})=>{throw await new Promise(t=>{e.addEventListener("abort",()=>t())}),new Error(e.reason instanceof Error?e.reason.message:String(e.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.stopReason).toBe("timeout"),expect(n.finalized?.body).toMatch(/timed out/);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"timeout"}),expect(t.size()).toBe(0)}),it("chat_cancel path: PromptRunStore.abort fires user_stop → stream.cancel()",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({abortSignal:e})=>(s(),await new Promise((t,a)=>{e.addEventListener("abort",()=>{const t=e.reason;a(t instanceof Error?t:new Error(String(t)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n;const i=t.abort("msg_t1","chat_cancel:user_request");expect(i.ok).toBe(!0),await expect(r).rejects.toThrow(/chat_cancel:user_request/);const c=e.streams[0];expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop");const p=a.find(e=>"dispatch_error"===e.type);expect(p).toMatchObject({cause:"abort"}),expect(t.size()).toBe(0)}),it("runtime error path: stream is failed with the runtime's error message",async()=>{const s=makeRuntime(async()=>{throw new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/);const n=e.streams[0];expect(n.finalized?.state).toBe("failed"),expect(n.finalized?.body).toBe("LLM exploded");const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({cause:"runtime_error"}),expect(t.size()).toBe(0)}),it("registers with PromptRunStore between dispatch_start and dispatch_complete",async()=>{let s=-1;const n=makeRuntime(async()=>(s=t.size(),{counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(n,e,t,a)),expect(s).toBe(1),expect(t.size()).toBe(0)}),it("duplicate inboundMessageId returns false and emits dispatch_error with cause=abort",async()=>{t.register("msg_t1",{requestId:"im_req_existing",channelId:"ch_t1",abortController:new AbortController,startedAt:Date.now()});const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!1),expect(e.streams).toHaveLength(0);const o=a.find(e=>"dispatch_error"===e.type);expect(o).toMatchObject({error:"duplicate_inbound_message_id",cause:"abort"})}),it("uses inboundMessageId as replyTo by default",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a)),expect(e.startStreamCalls[0].replyTo).toBe("msg_t1")}),it("honors inReplyTo override",async()=>{const s=makeRuntime(async()=>({counts:{tool:0,block:0,final:0}}));await dispatchImInboundPrompt(baseArgs(s,e,t,a,{inReplyTo:"custom_parent_msg"})),expect(e.startStreamCalls[0].replyTo).toBe("custom_parent_msg")}),it("reasoning happy path: opens an agent_reasoning row, finalizes it completed, backfills thinking",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"let me think"}),t.onReasoningStream({text:"let me think hard"}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(2);const o=e.streams[0],r=e.streams[1];expect(e.startStreamCalls[1].type).toBe("agent_reasoning"),expect(e.startStreamCalls[1].replyTo).toBe("msg_t1"),expect(r.body).toBe("let me think hard"),expect(r.finalized).toMatchObject({state:"completed",stopReason:"end_turn"}),expect(o.finalized?.state).toBe("completed"),expect(o.thinking).toEqual([{text:"let me think hard",state:"done"}])}),it("reasoning timeout path: reasoning row is failed (mirrors the failed reply)",async()=>{const s=makeRuntime(async({replyOptions:e,abortSignal:t})=>{throw e.onReasoningStream({text:"thinking..."}),await new Promise(e=>{t.addEventListener("abort",()=>e())}),new Error(t.reason instanceof Error?t.reason.message:String(t.reason))});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a,{promptTimeoutMs:5}))).rejects.toThrow(/timed out after 5ms/),expect(e.streams).toHaveLength(2);const n=e.streams[0],o=e.streams[1];expect(n.finalized?.state).toBe("failed"),expect(o.finalized?.state).toBe("failed"),expect(o.finalized?.stopReason).toBe("timeout")}),it("reasoning chat_cancel path: reasoning row is cancelled (mirrors the cancelled reply)",async()=>{let s;const n=new Promise(e=>{s=e}),o=makeRuntime(async({replyOptions:e,abortSignal:t})=>(e.onReasoningStream({text:"thinking..."}),s(),await new Promise((e,a)=>{t.addEventListener("abort",()=>{const e=t.reason;a(e instanceof Error?e:new Error(String(e)))})}),{counts:{tool:0,block:0,final:0}})),r=dispatchImInboundPrompt(baseArgs(o,e,t,a));await n,t.abort("msg_t1","chat_cancel:user_request"),await expect(r).rejects.toThrow(/chat_cancel:user_request/),expect(e.streams).toHaveLength(2);const i=e.streams[0],c=e.streams[1];expect(i.finalized?.state).toBe("cancelled"),expect(c.finalized?.state).toBe("cancelled"),expect(c.finalized?.stopReason).toBe("user_stop")}),it("reasoning runtime error path: reasoning row is failed",async()=>{const s=makeRuntime(async({replyOptions:e})=>{throw e.onReasoningStream({text:"thinking..."}),new Error("LLM exploded")});await expect(dispatchImInboundPrompt(baseArgs(s,e,t,a))).rejects.toThrow(/LLM exploded/),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("failed"),expect(e.streams[1].finalized?.state).toBe("failed")}),it("no reasoning chunk: only the reply row is opened (terminateReasoning is a no-op)",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(n).toBe(!0),expect(e.streams).toHaveLength(1),expect(e.streams[0].finalized?.state).toBe("completed")}),it("reasoning finalize failure: flags reasoningTerminalWriteFailed, reply unaffected",async()=>{const s=makeRuntime(async({dispatcher:e,replyOptions:t})=>(t.onReasoningStream({text:"thinking..."}),t.onReasoningEnd(),e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 2===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0),expect(e.streams).toHaveLength(2),expect(e.streams[0].finalized?.state).toBe("completed");const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({reasoningTerminalWriteFailed:!0}),expect(r).not.toHaveProperty("terminalWriteFailed"),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()}),it("success-path finalize failure: returns true and flags terminalWriteFailed",async()=>{const s=makeRuntime(async({dispatcher:e})=>(e.sendFinalReply({text:"Answer."}),{counts:{tool:0,block:0,final:1}})),n=e.messages.startStream;e.messages.startStream=t=>{const a=n(t);return 1===e.streams.length&&(a.failNextTerminal=!0),a};const o=await dispatchImInboundPrompt(baseArgs(s,e,t,a));expect(o).toBe(!0);const r=a.find(e=>"dispatch_complete"===e.type);expect(r).toMatchObject({terminalWriteFailed:!0}),expect(a.find(e=>"dispatch_error"===e.type)).toBeUndefined()})});
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{MessageClient}from"@beeos-ai/message-sdk";import{extractChatPrompt}from"@beeos-ai/message-sdk/chat-envelope";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async o=>{const{createPrivateKey:r,sign:a}=await import("node:crypto"),t=r({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(a(null,Buffer.from(o),t))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",r=>{const a=r instanceof Error?r.message:String(r);e.logger.error?.("[beeos-claw] unhandledRejection:",r),o({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:a,error:{code:"unhandled_rejection",message:a}})}))}function initializePlugin(e){let o,r;try{o=e.runtime?.config?.loadConfig?.()}catch{}try{r=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:o})}catch(o){return e.logger.error?.("[beeos-claw] Failed to resolve config:",o),null}const a=r.config,t=buildThirdPartyChannelSet(o);t.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...t].join(", ")}`);const s=a.log.enabled?createObsEventWriter({dir:a.log.dir,redaction:{gatewayToken:a.gateway.token},logger:e.logger}):()=>{};installUnhandledRejectionObserver(e,s),s({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),s({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:a,sources:r.sources,openclawConfigPath:r.openclawConfigPath}});for(const o of r.validation)s({component:"plugin",domain:"config",name:"config.validation",severity:o.severity,summary:o.message,error:{code:o.code,message:o.message,nextSteps:o.nextSteps}}),"error"===o.severity?e.logger.error?.(`[beeos-claw] ${o.message}`):e.logger.warn?.(`[beeos-claw] ${o.message}`);if(r.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),a.keyFile)try{agentKeys=loadAgentKeyPair(a.keyFile);const o=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${o})`)}catch(o){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${o}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&a.agentGatewayUrl&&(setUploadDeps({gatewayUrl:a.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:a,writeObs:s,thirdPartyChannels:t,openclawConfig:o}}function registerUploadTools(e,o,r){const{cfg:a,writeObs:t}=o;cleanupUploadToolResultCache().catch(o=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(o)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(o,r,s){e.logger.debug?.(`[beeos_upload_file] toolCallId=${o} params=${JSON.stringify(r)}`);const n=await executeUpload(r,a,agentKeys,s);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${o} ok=${n.ok}`),t({component:"upload-tool",domain:"tool",name:n.ok?"upload.success":"upload.failure",severity:n.ok?"info":"warn",payload:{toolCallId:o,ok:n.ok,fileCount:n.files?.length??0,error:n.error}}),(async r=>{try{await writeUploadToolResult(o,BEEOS_UPLOAD_TOOL_NAME,r),await cleanupUploadToolResultCache()}catch(r){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${o}: ${String(r)}`)}return r})(n)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,r){await handleFileStage(e,r,o.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,o){handleStatusRequest(e,o,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}})}function registerServices(e,o,r){const{cfg:a,writeObs:t,thirdPartyChannels:s}=o,n=agentKeys?(e,o)=>agentFetch(e,agentKeys,o):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedAcpServer||(sharedAcpServer=new ACPServer(a,e.logger,t,n,i,s));const l=sharedAcpServer;r.current=l,e.registerService({id:"beeos-acp",async start(){const o=Date.now(),r=_pluginRegisteredAt?o-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${r}ms, bridge.url=${a.bridge.url||"(empty)"}`),!a.bridge.url&&a.agentGatewayUrl&&n){const o=Date.now(),r=await discoverBridgeFromGW(a.agentGatewayUrl,n);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-o}ms result=${r?.url??"null"}`),r)a.bridge.url=r.url,writeDiscoveryCache({bridge:{url:r.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${r.url}`);else{const o=readDiscoveryCache();o?.bridge?.url?(a.bridge.url=o.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${o.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await l.start(a.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-o}ms)`)},async stop(){await l.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const g=new TaskHandleRegistry;let c=null;const{enabled:d,url:p,principalId:u,a2aEnabled:f}=a.messageBus;let y;if(a.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);y=createTokenProvider({agentGatewayUrl:a.agentGatewayUrl,identity:e})}if(d&&(y||a.messageBus.serviceUrl)){c=new MessageClient({serviceUrl:a.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:p,tokenProvider:y,logger:e.logger});const o=u,r=async o=>{c.once("connect",()=>{l.setMessageClient(c,o),e.logger.info?.("[beeos-claw] MessageClient connected (principalId=%s, via=%s)",o,y?"gateway":"direct")}),await c.connect({identityId:o})};e.registerService({id:"beeos-message-bus",async start(){let a=o;if(a||(a=process.env.BEEOS_PRINCIPAL_ID||""),!a&&l)try{await l.waitForAgentMapping(),a=l.getDefaultPlatformAgent()?.instanceId||""}catch(o){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",o)}a?await r(a):l?(e.logger.info?.("[beeos-claw] Message bus deferred: waiting for late agent sync..."),l.onceAgentMappingReady(()=>{const o=l.getDefaultPlatformAgent()?.instanceId;o?r(o).catch(o=>{e.logger.error?.("[beeos-claw] Deferred message bus connect failed:",o)}):e.logger.warn?.("[beeos-claw] Agent mapping ready but no principalId, message bus stays disabled")})):e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled")},async stop(){await(c?.disconnect()),e.logger.info?.("[beeos-claw] MessageClient disconnected")}});const t=async(o,r)=>{const a=extractChatPrompt(r,{source:o,localAgentId:l.getLocalAgentId()});a&&(e.logger.debug?.(`[beeos-claw] ${o} received (sessionKey=%s, contextId=%s)`,a.sessionKey,a.contextId??"-"),await l.agentSend(a.message,a.files,a.sessionKey,void 0,a.channelId,a.messageId))};let s=!1;c.on("message",o=>{(async()=>{"chat_message"===o.type?await t("chat_message",o):"agent_request"===o.type&&(s||(e.logger.warn?.("[beeos-claw] Received legacy 'agent_request' message — this path is deprecated and will be removed once all upstreams migrate to 'chat_message' (L0 AgentInvocationService)."),s=!0),await t("agent_request",o))})().catch(o=>{e.logger.error?.("[beeos-claw] message handler failed:",o)})})}else d?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",d);if(!1!==f&&a.agentGatewayUrl){const o=e=>l.getGatewaySessionKeyForToolCall(e),r=e=>l.getMentionsForToolCall(e),t=(e,o)=>{l.broadcastNotification(e,o)};registerA2ATools(e,{agentGatewayUrl:a.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},g,o,r,t,()=>l?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(a.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const o=sharedCanvasState;if(a.agentGatewayUrl&&agentKeys){let r;o.setLogger(e.logger);{const e=a.agentGatewayUrl,o=agentKeys;r=async()=>{const r=await agentFetch(`${e}/api/v1/canvas/token`,o,{method:"POST"});if(!r.ok){const e=await r.text().catch(()=>"");throw new Error(`Canvas token returned ${r.status}: ${e}`)}return r.json()}}const t=t=>{const s=new CanvasRelayClient({canvasRelayUrl:a.canvasRelayUrl,agentKeys:agentKeys,agentId:t.id,displayName:t.displayName,avatarUrl:t.avatarUrl,tokenProvider:r,onReconnect:()=>l.onRelayReconnected(),logger:e.logger});s.connect(),o.setRelayClient(s),o.setAgentId(t.id),l.wireRelayActionHandler(),l.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${t.id}, name=${t.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await l.waitForAgentMapping();const o=l.getDefaultPlatformAgent();if(o?.id)return void t(o);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),l.onceAgentMappingReady(()=>{const o=l.getDefaultPlatformAgent();if(o?.id)try{t(o)}catch(o){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${o}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(o){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${o}`)}},stop(){o.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const r={stateManager:o,logger:e.logger,writeObs:e=>t(e),isCanvasActive:e=>!e||l.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>l.getSessionIdForToolCall(e),getPlatformAgentId:e=>l.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>l.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>l.waitForAgentMapping()},n=new FormSchemaRegistry,i=buildCronSchemas(s,{capMs:a.cronMaxDurationCapMs});n.register(i.create),n.register(i.update);const g=new ResourceDescriptorRegistry;g.register(CRON_RESOURCE);const c=e=>async(o,r,a)=>(l.preMapToolCallSession(o),e(o,r));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:c(createRenderUiHandler(r))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:c(createCloseUiHandler(r))}),l.setCanvasState(o,{schemaRegistry:n,descriptorRegistry:g});const d=l.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(n),parameters:RENDER_FORM_TOOL_SCHEMA,execute:c(createRenderFormHandler({registry:n,builder:d,logger:e.logger}))});const p=l.getResourceRenderer();if(p){generateActionTools(g,e,async(e,o)=>l.gatewayRequest(e,o),p,e.logger)}const u='\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.',f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),y=256,m=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,o)=>{if(o?.sessionKey){if(m.size>=y&&!m.has(o.sessionKey)){const e=m.keys().next().value;void 0!==e&&m.delete(e)}m.set(o.sessionKey,o.channelId)}if(isThirdPartyChannel(s,{sessionKey:o?.sessionKey,channelId:o?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!o?.sessionKey)return{appendSystemContext:u};return l.isCanvasActiveForGatewayKey(o.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,o)=>{if(o?.sessionKey&&l.setPendingToolCallSessionKey(o.sessionKey),!f.has(e.toolName))return;const r=o?.sessionKey?m.get(o.sessionKey):void 0;return isThirdPartyChannel(s,{sessionKey:o?.sessionKey,channelId:r})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:o?.sessionKey?l.isCanvasActiveForGatewayKey(o.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const m=new TerminalManager,b=createTerminalWebSocketService({config:{port:a.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:a.terminalWsAllowedOrigins},terminalMgr:m,logger:e.logger,writeObs:t});if(a.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){b.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${a.terminalWsPort}`)},stop(){b.stop(),m.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),o.openclawConfig){const r=mirrorOpenClawConfig(o.openclawConfig);r.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${r.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${r.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{const o=initializePlugin(e);if(!o)return;const r={current:null};registerUploadTools(e,o,r),registerServices(e,o,r),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
1
+ import{resolveConfigWithMeta,mirrorOpenClawConfig}from"./config.js";import{hasRuntimeReplyApi}from"./runtime/reply-router.js";import{PromptRunStore}from"./runtime/prompt-run-store.js";import{createImInboundHandler}from"./im-inbound-handler.js";import{createTokenProvider}from"@beeos-ai/message-sdk/auth";import{registerA2ATools,TaskHandleRegistry}from"./a2a/tools.js";import{BEEOS_UPLOAD_TOOL_NAME,BEEOS_UPLOAD_TOOL_SCHEMA,BEEOS_UPLOAD_TOOL_DESCRIPTION,executeUpload}from"./upload/tool.js";import{writeUploadToolResult,cleanupUploadToolResultCache}from"./upload/result-cache.js";import{handleFileStage}from"./upload/file-stage.js";import{handleStatusRequest}from"./status.js";import{ACPServer}from"./acp-server.js";import{createObsEventWriter}from"./observability.js";import{resolvePluginVersion}from"./plugin-version.js";import{createTerminalWebSocketService}from"./terminal/websocket.js";import{TerminalManager}from"./terminal/session-manager.js";import{loadAgentKeyPair,agentFetch}from"./agent-auth.js";import{readDiscoveryCache,writeDiscoveryCache}from"./discovery-cache.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{setUploadDeps}from"./upload/upload-file.js";import{registerLocalFileExtractor}from"./upload/local-file-extractors.js";import{browserScreenshotExtractor}from"./browser/screenshot-extractor.js";import{CanvasStateManager}from"./canvas/state.js";import{CanvasRelayClient}from"./canvas/relay-client.js";import{RENDER_UI_TOOL_NAME,RENDER_UI_TOOL_DESCRIPTION,RENDER_UI_TOOL_SCHEMA,CLOSE_UI_TOOL_NAME,CLOSE_UI_TOOL_DESCRIPTION,CLOSE_UI_TOOL_SCHEMA,createRenderUiHandler,createCloseUiHandler,CANVAS_SYSTEM_PROMPT}from"./canvas/tools.js";import{FormSchemaRegistry}from"./surface/form-schema-registry.js";import{buildCronSchemas}from"./surface/cron-schemas.js";import{RENDER_FORM_TOOL_NAME,RENDER_FORM_TOOL_SCHEMA,buildRenderFormDescription,createRenderFormHandler}from"./surface/render-form-tool.js";import{ResourceDescriptorRegistry}from"./surface/resource-descriptor-registry.js";import{CRON_RESOURCE}from"./surface/cron-resource.js";import{generateActionTools}from"./surface/action-tool-generator.js";import{buildThirdPartyChannelSet,isThirdPartyChannel}from"./channel-detection.js";const PLUGIN_VERSION=resolvePluginVersion();let agentKeys=null,sharedCanvasState=null,sharedAcpServer=null,_initializing=!1,_pluginRegisteredAt=0;const ED25519_PKCS8_PREFIX=Buffer.from("302e020100300506032b657004220420","hex");function agentKeyPairToIdentity(e){return{publicKeyBase64:()=>Buffer.from(e.publicKey).toString("base64"),sign:async r=>{const{createPrivateKey:o,sign:a}=await import("node:crypto"),t=o({key:32===e.privateKey.length?Buffer.concat([ED25519_PKCS8_PREFIX,Buffer.from(e.privateKey)]):Buffer.from(e.privateKey),format:"der",type:"pkcs8"});return new Uint8Array(a(null,Buffer.from(r),t))}}}let unhandledRejectionObserverInstalled=!1;function installUnhandledRejectionObserver(e,r,o){unhandledRejectionObserverInstalled||(unhandledRejectionObserverInstalled=!0,process.on("unhandledRejection",o=>{const a=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] unhandledRejection:",o),r({component:"plugin",domain:"fatal",name:"unhandled_rejection",severity:"error",summary:a,error:{code:"unhandled_rejection",message:a}})}),process.on("uncaughtException",o=>{const a=o instanceof Error?o.message:String(o);e.logger.error?.("[beeos-claw] uncaughtException:",o),r({component:"plugin",domain:"fatal",name:"uncaught_exception",severity:"error",summary:a,error:{code:"uncaught_exception",message:a}})}))}function initializePlugin(e){let r,o;try{r=e.runtime?.config?.loadConfig?.()}catch{}try{o=resolveConfigWithMeta({pluginConfig:e.pluginConfig??{},openclawConfig:r})}catch(r){return e.logger.error?.("[beeos-claw] Failed to resolve config:",r),null}const a=o.config,t=buildThirdPartyChannelSet(r);t.size>0&&e.logger.info?.(`[beeos-claw] Third-party channels detected: ${[...t].join(", ")}`);const n=a.log.enabled?createObsEventWriter({dir:a.log.dir,redaction:{gatewayToken:a.gateway.token},logger:e.logger}):()=>{};n({component:"plugin",domain:"lifecycle",name:"lifecycle.startup",severity:"info",payload:{pluginVersion:PLUGIN_VERSION,pid:process.pid,nodeVersion:process.version,cwd:process.cwd()}}),n({component:"plugin",domain:"config",name:"config.resolved",severity:"info",payload:{config:a,sources:o.sources,openclawConfigPath:o.openclawConfigPath}});for(const r of o.validation)n({component:"plugin",domain:"config",name:"config.validation",severity:r.severity,summary:r.message,error:{code:r.code,message:r.message,nextSteps:r.nextSteps}}),"error"===r.severity?e.logger.error?.(`[beeos-claw] ${r.message}`):e.logger.warn?.(`[beeos-claw] ${r.message}`);if(o.validation.some(e=>"error"===e.severity)&&e.logger.error?.("[beeos-claw] Plugin has configuration errors — some features may not work."),a.keyFile)try{agentKeys=loadAgentKeyPair(a.keyFile);const r=Buffer.from(agentKeys.publicKey).toString("base64");e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (publicKey: ${r})`)}catch(r){e.logger.warn?.(`[beeos-claw] Failed to load agent key pair: ${r}`),e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no key pair — signed requests disabled)`)}else e.logger.info?.(`[beeos-claw] v${PLUGIN_VERSION} registering (no keyFile configured)`);return agentKeys&&a.agentGatewayUrl&&(setUploadDeps({gatewayUrl:a.agentGatewayUrl,keys:agentKeys,logger:e.logger}),registerLocalFileExtractor(browserScreenshotExtractor),e.logger.info?.("[beeos-claw] Upload deps + browser screenshot extractor registered")),{cfg:a,writeObs:n,thirdPartyChannels:t,openclawConfig:r}}function registerUploadTools(e,r,o){const{cfg:a,writeObs:t}=r;cleanupUploadToolResultCache().catch(r=>{e.logger.warn?.(`[beeos_upload_file] cache cleanup failed on startup: ${String(r)}`)}),e.registerTool({name:BEEOS_UPLOAD_TOOL_NAME,description:BEEOS_UPLOAD_TOOL_DESCRIPTION,parameters:BEEOS_UPLOAD_TOOL_SCHEMA,async execute(r,o,n){e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} params=${JSON.stringify(o)}`);const s=await executeUpload(o,a,agentKeys,n);return e.logger.debug?.(`[beeos_upload_file] toolCallId=${r} ok=${s.ok}`),t({component:"upload-tool",domain:"tool",name:s.ok?"upload.success":"upload.failure",severity:s.ok?"info":"warn",payload:{toolCallId:r,ok:s.ok,fileCount:s.files?.length??0,error:s.error}}),(async o=>{try{await writeUploadToolResult(r,BEEOS_UPLOAD_TOOL_NAME,o),await cleanupUploadToolResultCache()}catch(o){e.logger.warn?.(`[beeos_upload_file] persist cache failed toolCallId=${r}: ${String(o)}`)}return o})(s)}}),e.registerHttpRoute({path:"/beeos/files/stage",auth:"gateway",async handler(e,o){await handleFileStage(e,o,r.cfg)}}),e.registerHttpRoute({path:"/beeos/status",auth:"plugin",handler(e,r){handleStatusRequest(e,r,{publicKey:agentKeys?.publicKey??null,pluginVersion:PLUGIN_VERSION})}})}function registerServices(e,r,o){const{cfg:a,writeObs:t,thirdPartyChannels:n}=r,s=agentKeys?(e,r)=>agentFetch(e,agentKeys,r):void 0,i=agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):void 0;sharedAcpServer||(sharedAcpServer=new ACPServer(a,e.logger,t,s,i,n));const l=sharedAcpServer;o.current=l;const c=new PromptRunStore;e.registerService({id:"beeos-acp",async start(){const r=Date.now(),o=_pluginRegisteredAt?r-_pluginRegisteredAt:-1;if(e.logger.info?.(`[beeos-claw] beeos-acp service start() begin, sincePluginRegister=${o}ms, bridge.url=${a.bridge.url||"(empty)"}`),!a.bridge.url&&a.agentGatewayUrl&&s){const r=Date.now(),o=await discoverBridgeFromGW(a.agentGatewayUrl,s);if(e.logger.info?.(`[beeos-claw] bridge discovery took ${Date.now()-r}ms result=${o?.url??"null"}`),o)a.bridge.url=o.url,writeDiscoveryCache({bridge:{url:o.url,discoveredAt:Date.now()}}),e.logger.info?.(`[beeos-claw] Bridge URL discovered from Agent Gateway: ${o.url}`);else{const r=readDiscoveryCache();r?.bridge?.url?(a.bridge.url=r.bridge.url,e.logger.info?.(`[beeos-claw] Bridge URL from cache (GW unreachable): ${r.bridge.url}`)):e.logger.debug?.("[beeos-claw] Bridge URL not available (no GW, no cache) — bridge disabled")}}await l.start(a.bridge.url||void 0),e.logger.info?.(`[beeos-claw] ACP Server started (total service start: ${Date.now()-r}ms)`)},async stop(){await l.stop(),e.logger.info?.("[beeos-claw] ACP Server stopped")}});const{enabled:g,url:d,principalId:p,a2aEnabled:u}=a.messageBus;let f;if(a.agentGatewayUrl&&agentKeys){const e=agentKeyPairToIdentity(agentKeys);f=createTokenProvider({agentGatewayUrl:a.agentGatewayUrl,identity:e})}const m=g&&(f||a.messageBus.serviceUrl),y=m?l.getMessageClient():null;if(m&&y){y.on("disconnect",r=>{e.logger.warn?.("[beeos-claw] message bus disconnected (code=%s, reason=%s) — SDK will auto-reconnect",r?.code??"?",r?.reason??"?")}),y.on("error",r=>{e.logger.warn?.("[beeos-claw] message bus error: %s",r instanceof Error?r.message:String(r))});const r=p,o=async r=>{l.setMessageClient({serviceUrl:a.messageBus.serviceUrl||"https://msg.invalid",centrifugoUrl:d,tokenProvider:f,logger:e.logger},r),y.once("connect",()=>{e.logger.info?.("[beeos-claw] MessageClient connected (principalId=%s, via=%s)",r,f?"gateway":"direct")}),await y.connect({identityId:r})};e.registerService({id:"beeos-message-bus",async start(){let a=r;if(a||(a=process.env.BEEOS_PRINCIPAL_ID||""),!a&&l)try{await l.waitForAgentMapping(),a=l.getDefaultPlatformAgent()?.instanceId||""}catch(r){e.logger.warn?.("[beeos-claw] Failed to resolve principalId from agent sync:",r)}a?await o(a):l?(e.logger.info?.("[beeos-claw] Message bus deferred: waiting for late agent sync..."),l.onceAgentMappingReady(()=>{const r=l.getDefaultPlatformAgent()?.instanceId;r?o(r).catch(r=>{e.logger.error?.("[beeos-claw] Deferred message bus connect failed:",r)}):e.logger.warn?.("[beeos-claw] Agent mapping ready but no principalId, message bus stays disabled")})):e.logger.warn?.("[beeos-claw] No principalId resolved, message bus disabled")},async stop(){await(y?.disconnect()),e.logger.info?.("[beeos-claw] MessageClient disconnected")}});const n=createImInboundHandler({runtime:e.runtime,pluginConfig:e.pluginConfig,client:y,localServer:l,promptRunStore:c,logger:e.logger,writeObs:t,principalIdOverride:p,promptTimeoutMs:a.promptTimeoutMs});y.on("message",async e=>{"chat_message"===e.type?await n.onChatMessage(e):"chat_cancel"===e.type&&n.onChatCancel(e)})}else g?e.logger.warn?.("[beeos-claw] Message bus enabled but MESSAGE_SERVICE_URL not set — disabled"):e.logger.info?.("[beeos-claw] Message bus disabled (enabled=%s)",g);if(!1!==u&&a.agentGatewayUrl){const r=e=>l.getGatewaySessionKeyForToolCall(e),o=e=>l.getMentionsForToolCall(e),t=(e,r)=>{l.broadcastNotification(e,r)},n=new TaskHandleRegistry;registerA2ATools(e,{agentGatewayUrl:a.agentGatewayUrl,agentKeys:agentKeys,logger:e.logger},n,r,o,t,()=>l?.getDefaultPlatformAgent()?.instanceId),e.logger.info?.("[beeos-claw] A2A tools registered (beeos_discover_agents, beeos_call_agent) via REST")}if(a.canvasEnabled){sharedCanvasState||(sharedCanvasState=new CanvasStateManager);const r=sharedCanvasState;if(a.agentGatewayUrl&&agentKeys){let o;r.setLogger(e.logger);{const e=a.agentGatewayUrl,r=agentKeys;o=async()=>{const o=await agentFetch(`${e}/api/v1/canvas/token`,r,{method:"POST"});if(!o.ok){const e=await o.text().catch(()=>"");throw new Error(`Canvas token returned ${o.status}: ${e}`)}return o.json()}}const t=t=>{const n=new CanvasRelayClient({canvasRelayUrl:a.canvasRelayUrl,agentKeys:agentKeys,agentId:t.id,displayName:t.displayName,avatarUrl:t.avatarUrl,tokenProvider:o,onReconnect:()=>l.onRelayReconnected(),logger:e.logger});n.connect(),r.setRelayClient(n),r.setAgentId(t.id),l.wireRelayActionHandler(),l.flushPendingCanvasLinks(),e.logger.info?.(`[beeos-claw] Canvas Relay multiplexed client connected (agentId=${t.id}, name=${t.name??"none"})`)};e.registerService({id:"beeos-canvas-relay",async start(){try{await l.waitForAgentMapping();const r=l.getDefaultPlatformAgent();if(r?.id)return void t(r);e.logger.info?.("[beeos-claw] Canvas Relay deferred: waiting for late agent sync..."),l.onceAgentMappingReady(()=>{const r=l.getDefaultPlatformAgent();if(r?.id)try{t(r)}catch(r){e.logger.warn?.(`[beeos-claw] Deferred Canvas Relay start failed: ${r}`)}else e.logger.warn?.("[beeos-claw] Agent mapping ready but no platform agent; Canvas Relay stays disabled")})}catch(r){e.logger.warn?.(`[beeos-claw] Failed to start Canvas Relay client: ${r}`)}},stop(){r.getRelayClient()?.disconnect()}}),e.logger.info?.("[beeos-claw] Canvas Relay configured")}else e.logger.warn?.("[beeos-claw] Canvas Relay unavailable: agentGatewayUrl or agentKeys missing");const o={stateManager:r,logger:e.logger,writeObs:e=>t(e),isCanvasActive:e=>!e||l.isCanvasActiveForGatewayKey(e),getActiveSessionId:e=>l.getSessionIdForToolCall(e),getPlatformAgentId:e=>l.getPlatformAgentIdForToolCall(e),getGatewaySessionKey:e=>l.getGatewaySessionKeyForToolCall(e),waitForAgentSync:()=>l.waitForAgentMapping()},s=new FormSchemaRegistry,i=buildCronSchemas(n,{capMs:a.cronMaxDurationCapMs});s.register(i.create),s.register(i.update);const c=new ResourceDescriptorRegistry;c.register(CRON_RESOURCE);const g=e=>async(r,o,a)=>(l.preMapToolCallSession(r),e(r,o));e.registerTool({name:RENDER_UI_TOOL_NAME,description:RENDER_UI_TOOL_DESCRIPTION,parameters:RENDER_UI_TOOL_SCHEMA,execute:g(createRenderUiHandler(o))}),e.registerTool({name:CLOSE_UI_TOOL_NAME,description:CLOSE_UI_TOOL_DESCRIPTION,parameters:CLOSE_UI_TOOL_SCHEMA,execute:g(createCloseUiHandler(o))}),l.setCanvasState(r,{schemaRegistry:s,descriptorRegistry:c});const d=l.getFormBuilder();d&&e.registerTool({name:RENDER_FORM_TOOL_NAME,description:buildRenderFormDescription(s),parameters:RENDER_FORM_TOOL_SCHEMA,execute:g(createRenderFormHandler({registry:s,builder:d,logger:e.logger}))});const p=l.getResourceRenderer();if(p){generateActionTools(c,e,async(e,r)=>l.gatewayRequest(e,r),p,e.logger)}const u='\n\nTool selection guidance:\n- browser tool: Use when the user wants to OPEN, VISIT, NAVIGATE to a webpage, take SCREENSHOTS, interact with elements (click, type), or visually inspect pages.\n- beeos_search: Use ONLY for text-based information retrieval (fact-finding, research queries). Never for page interaction or screenshots.\n- beeos_fetch: Use ONLY to extract article text from a known URL. Never for visual content or screenshots.\nWhen the user says "open", "visit", "go to", "screenshot", or mentions seeing/viewing a webpage, ALWAYS prefer the browser tool.',f=new Set([RENDER_UI_TOOL_NAME,CLOSE_UI_TOOL_NAME,RENDER_FORM_TOOL_NAME]),m=256,y=new Map;if("function"==typeof e.on){try{e.on("before_prompt_build",(e,r)=>{if(r?.sessionKey){if(y.size>=m&&!y.has(r.sessionKey)){const e=y.keys().next().value;void 0!==e&&y.delete(e)}y.set(r.sessionKey,r.channelId)}if(isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:r?.channelId}))return{appendSystemContext:'This conversation is in a third-party channel (Lark/Telegram/Discord/Slack); Canvas surfaces are not visible here. Do NOT use render_ui, close_ui, or render_form. Use built-in tools directly (e.g. the `cron` tool with `action:"add"` for scheduled tasks) and respond with plain text.'};if(!r?.sessionKey)return{appendSystemContext:u};return l.isCanvasActiveForGatewayKey(r.sessionKey)?{appendSystemContext:CANVAS_SYSTEM_PROMPT+u}:{appendSystemContext:"Canvas is closed by user. Do NOT use render_ui, close_ui, or render_form. Respond with plain text only."+u}}),e.logger.debug?.("[beeos-claw] Canvas system prompt hook registered: before_prompt_build")}catch{e.logger.debug?.("[beeos-claw] before_prompt_build hook not supported")}try{e.on("before_tool_call",(e,r)=>{if(r?.sessionKey&&l.setPendingToolCallSessionKey(r.sessionKey),!f.has(e.toolName))return;const o=r?.sessionKey?y.get(r.sessionKey):void 0;return isThirdPartyChannel(n,{sessionKey:r?.sessionKey,channelId:o})?{block:!0,blockReason:"Canvas tools are not available in this channel. Respond with plain text only."}:r?.sessionKey?l.isCanvasActiveForGatewayKey(r.sessionKey)?void 0:{block:!0,blockReason:"Canvas is closed by user. Respond with plain text instead."}:{block:!0,blockReason:"Canvas session unknown. Respond with plain text instead."}}),e.logger.debug?.("[beeos-claw] Canvas tool guard registered: before_tool_call")}catch{e.logger.debug?.("[beeos-claw] before_tool_call hook not supported")}}e.logger.info?.("[beeos-claw] Canvas tools registered (render_ui, close_ui, render_form + auto-action tools)")}else e.logger.info?.("[beeos-claw] Canvas disabled by config (canvasEnabled=false)");const b=new TerminalManager,w=createTerminalWebSocketService({config:{port:a.terminalWsPort,token:agentKeys?Buffer.from(agentKeys.publicKey).toString("base64"):"",allowedOrigins:a.terminalWsAllowedOrigins},terminalMgr:b,logger:e.logger,writeObs:t});if(a.bridge.shell.enabled?e.registerService({id:"beeos-terminal-ws",start(){w.start(),e.logger.info?.(`[beeos-claw] Terminal WS started on port ${a.terminalWsPort}`)},stop(){w.stop(),b.destroy(),e.logger.info?.("[beeos-claw] Terminal WS stopped")}}):e.logger.info?.("[beeos-claw] Terminal/shell disabled by config (bridge.shell.enabled=false)"),r.openclawConfig){const o=mirrorOpenClawConfig(r.openclawConfig);o.copied?e.logger.info?.(`[beeos-claw] mirrored openclaw config to ${o.destinationPath}`):e.logger.debug?.(`[beeos-claw] openclaw config mirror skipped: ${o.reason}`)}}const CAPABILITY_REGISTRATION_MODES=new Set(["full","discovery","tool-discovery"]),plugin={id:"beeos-claw",name:"beeos-claw",description:"BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",version:PLUGIN_VERSION,register(e){if(!_initializing)if(!e.registrationMode||CAPABILITY_REGISTRATION_MODES.has(e.registrationMode)){_initializing=!0;try{if(!hasRuntimeReplyApi(e.runtime))throw e.logger.error?.("[beeos-claw] FATAL: OpenClaw runtime.channel.reply.dispatchReplyFromConfig not exposed. Upgrade OpenClaw or revert beeos-claw."),new Error("openclaw_runtime_reply_api_unavailable");const r=initializePlugin(e);if(!r)return;const o={current:null};installUnhandledRejectionObserver(e,r.writeObs,o),registerUploadTools(e,r,o),registerServices(e,r,o),_pluginRegisteredAt=Date.now(),e.logger.info?.("[beeos-claw] Plugin registered successfully")}finally{_initializing=!1}}else e.logger.debug?.(`[beeos-claw] Skipping registration (mode=${e.registrationMode})`)}};export default plugin;
@@ -1 +1 @@
1
- import{readFileSync}from"node:fs";import{fileURLToPath}from"node:url";const DEFAULT_VERSION="0.1.0";function readVersionFromJson(o){try{const e=readFileSync(fileURLToPath(o),"utf-8"),n=JSON.parse(e);if(n&&"object"==typeof n&&"string"==typeof n.version)return n.version.trim()||void 0}catch{}}export function resolvePluginVersion(){const o=[new URL("../openclaw.plugin.json",import.meta.url),new URL("../../openclaw.plugin.json",import.meta.url),new URL("../package.json",import.meta.url),new URL("../../package.json",import.meta.url)];for(const e of o){const o=readVersionFromJson(e);if(o)return o}return"0.1.0"}
1
+ import{readFileSync}from"node:fs";import{fileURLToPath}from"node:url";const DEFAULT_VERSION="unknown";function readVersionFromJson(o){try{const n=readFileSync(fileURLToPath(o),"utf-8"),e=JSON.parse(n);if(e&&"object"==typeof e&&"string"==typeof e.version)return e.version.trim()||void 0}catch{}}export function resolvePluginVersion(){const o=[new URL("../package.json",import.meta.url),new URL("../../package.json",import.meta.url),new URL("../openclaw.plugin.json",import.meta.url),new URL("../../openclaw.plugin.json",import.meta.url)];for(const n of o){const o=readVersionFromJson(n);if(o)return o}return"unknown"}
@@ -0,0 +1 @@
1
+ export class SerializedCallbackQueue{pending=[];idleWaiters=[];running=!1;failure;enqueueSync(i){void 0===this.failure&&(this.pending.push({run:i}),this.running||this.pump())}waitForIdle(){return void 0!==this.failure?Promise.reject(this.failure):this.running||0!==this.pending.length?new Promise((i,e)=>{this.idleWaiters.push({resolve:i,reject:e})}):Promise.resolve()}fail(i){for(void 0===this.failure&&(this.failure=i),this.running=!1,this.pending=[];this.idleWaiters.length>0;){this.idleWaiters.shift().reject(this.failure)}}pendingCount(){return this.pending.length}isFailed(){return void 0!==this.failure}pump(){if(!this.running&&void 0===this.failure){for(;this.pending.length>0;){const i=this.pending.shift();this.running=!0;try{i.run(),this.running=!1}catch(i){return this.running=!1,void this.fail(i)}}this.resolveIdle()}}resolveIdle(){if(!(this.running||this.pending.length>0||void 0!==this.failure))for(;this.idleWaiters.length>0;){this.idleWaiters.shift().resolve()}}}
@@ -0,0 +1 @@
1
+ import{describe,it,expect}from"vitest";import{SerializedCallbackQueue}from"./callback-queue.js";describe("SerializedCallbackQueue",()=>{it("runs sync callbacks immediately in FIFO order",()=>{const e=new SerializedCallbackQueue,a=[];e.enqueueSync(()=>a.push(1)),e.enqueueSync(()=>a.push(2)),e.enqueueSync(()=>a.push(3)),expect(a).toEqual([1,2,3]),expect(e.pendingCount()).toBe(0)}),it("waitForIdle resolves immediately when queue is empty",async()=>{const e=new SerializedCallbackQueue;await expect(e.waitForIdle()).resolves.toBeUndefined()}),it("waitForIdle resolves after all enqueued callbacks have run",async()=>{const e=new SerializedCallbackQueue,a=[];e.enqueueSync(()=>a.push(1)),e.enqueueSync(()=>a.push(2)),await e.waitForIdle(),expect(a).toEqual([1,2])}),it("supports multiple concurrent waitForIdle awaiters",async()=>{const e=new SerializedCallbackQueue,a=e.waitForIdle(),t=e.waitForIdle();await Promise.all([a,t])}),it("poisons the queue when a callback throws and rejects waiters",async()=>{const e=new SerializedCallbackQueue,a=[];e.enqueueSync(()=>a.push(1)),e.enqueueSync(()=>{throw new Error("boom")}),e.enqueueSync(()=>a.push(2)),expect(a).toEqual([1]),expect(e.isFailed()).toBe(!0),await expect(e.waitForIdle()).rejects.toThrow(/boom/)}),it("rejects pre-existing waiters when fail() is called explicitly",async()=>{const e=new SerializedCallbackQueue;e.fail(new Error("explicit")),expect(e.isFailed()).toBe(!0),await expect(e.waitForIdle()).rejects.toThrow(/explicit/)}),it("subsequent enqueueSync after failure is a silent no-op",()=>{const e=new SerializedCallbackQueue;e.fail(new Error("dead"));const a=[];e.enqueueSync(()=>a.push(1)),expect(a).toEqual([])}),it("preserves FIFO order across reentrant enqueues",()=>{const e=new SerializedCallbackQueue,a=[];e.enqueueSync(()=>{a.push(1),e.enqueueSync(()=>a.push(2))}),e.enqueueSync(()=>a.push(3)),expect(a).toEqual([1,2,3])})});
@@ -0,0 +1 @@
1
+ import{randomUUID}from"node:crypto";const isNonEmptyString=e=>"string"==typeof e&&e.length>0,extractBodyUpdate=(e,t)=>{if(!isNonEmptyString(t))return{kind:"noop"};if(t===e.text)return{kind:"noop"};if(t.startsWith(e.text)){const n=t.slice(e.text.length);return e.text=t,{kind:"append",text:n}}return e.text.startsWith(t)?{kind:"noop"}:(e.text=t,{kind:"replace",text:t})};export function createImReplySink(e){const t={text:""};let n,r="",o="";const s=[],a={tool:0,block:0,final:0};let i=!1;const d=(t,n)=>{e.callbackQueue.enqueueSync(()=>{if((t=>e.abortSignal.aborted?(e.logger.debug?.(`[im-sink] ${t} ignored after abort requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):e.stream.isTerminated?(e.logger.debug?.(`[im-sink] ${t} ignored after stream_terminated requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1):!i||(e.logger.debug?.(`[im-sink] ${t} ignored after mark_complete requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`),!1))(t))try{n()}catch(n){const r=n instanceof Error?n.message:String(n);e.logger.warn?.(`[im-sink] ${t} mapping failed requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId} error=${r}`)}})};return{dispatcher:{sendToolResult:t=>(d("tool_result",()=>{a.tool+=1;const n=s.pop()??`tool_${e.requestId}_${a.tool}`,r=isNonEmptyString(t.text)?t.text:t.mediaUrl??"";e.stream.appendToolResult(n,r,!0===t.isError)}),!0),sendBlockReply:n=>(d("block_reply",()=>{a.block+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),sendFinalReply:n=>(d("final_reply",()=>{a.final+=1,isNonEmptyString(n.text)&&(e.stream.setBody(n.text),t.text=n.text)}),!0),waitForIdle:()=>e.callbackQueue.waitForIdle(),getQueuedCounts:()=>({...a}),markComplete:()=>{i=!0}},replyOptions:{abortSignal:e.abortSignal,onAgentRunStart:t=>{e.logger.info?.(`[im-sink] run_start requestId=${e.requestId} runId=${t} channel=${e.channelId} in_reply_to=${e.inboundMessageId} origin=${e.origin??"im"}`)},onPartialReply:n=>{d("partial",()=>{const r=extractBodyUpdate(t,n.text);switch(r.kind){case"append":e.stream.appendBody(r.text);break;case"replace":e.stream.setBody(r.text)}})},onReasoningStream:t=>{d("reasoning",()=>{if(!isNonEmptyString(t.text))return;if(!n&&(n=e.openReasoningStream?.(),!n))return;const s=t.text;""===o&&""!==r&&(n.appendBody("\n"),r+="\n"),s!==o&&(s.startsWith(o)?(n.appendBody(s.slice(o.length)),o=s):o.startsWith(s)||(n.setBody(r+s),o=s))})},onReasoningEnd:()=>{d("reasoning_end",()=>{""!==o&&(e.stream.appendThinking(o,"done"),r+=o,o="")})},onAssistantMessageStart:()=>{e.logger.debug?.(`[im-sink] assistant_message_start requestId=${e.requestId} channel=${e.channelId} in_reply_to=${e.inboundMessageId}`)},onToolStart:t=>{d("tool_start",()=>{const n=isNonEmptyString(t.name)?t.name:"tool",r=`tool_${randomUUID()}`;s.push(r),e.stream.appendToolUse(r,n,{},"streaming")})}}}}
@@ -0,0 +1 @@
1
+ import{describe,it,expect,beforeEach}from"vitest";import{SerializedCallbackQueue}from"./callback-queue.js";import{createImReplySink}from"./im-reply-sink.js";class FakeMessageStream{calls=[];parts=[];body="";isTerminated=!1;appendBody(e){if(this.isTerminated)throw new Error("terminated");this.body+=e,this.calls.push({type:"appendBody",chunk:e})}setBody(e){if(this.isTerminated)throw new Error("terminated");this.body=e,this.calls.push({type:"setBody",text:e})}addPart(e){if(this.isTerminated)throw new Error("terminated");this.parts.push(e),this.calls.push({type:"addPart",part:e})}replacePart(e,t){if(this.isTerminated)throw new Error("terminated");this.parts[e]=t,this.calls.push({type:"replacePart",index:e,part:t})}appendThinking(e,t="done"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"thinking",text:e,state:t}),this.calls.push({type:"appendThinking",text:e,state:t})}appendToolUse(e,t,a,n="streaming"){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_use",id:e,name:t,arguments:a,state:n}),this.calls.push({type:"appendToolUse",id:e,name:t,args:a})}appendToolResult(e,t,a=!1){if(this.isTerminated)throw new Error("terminated");this.parts.push({type:"tool_result",tool_use_id:e,content:t,...a?{is_error:!0}:{}}),this.calls.push({type:"appendToolResult",toolUseId:e,content:t,...a?{isError:!0}:{}})}}const stubLogger=()=>{const e=[],t=t=>a=>e.push(`[${t}] ${a}`);return{lines:e,debug:t("debug"),info:t("info"),warn:t("warn"),error:t("error")}},buildDeps=(e,t,a,n)=>({stream:e,openReasoningStream:n?()=>n:void 0,abortSignal:t,callbackQueue:a,logger:stubLogger(),requestId:"im_req_test",channelId:"ch_test",inboundMessageId:"msg_test",origin:"im"});describe("createImReplySink",()=>{let e,t,a,n;beforeEach(()=>{e=new FakeMessageStream,t=new FakeMessageStream,a=new AbortController,n=new SerializedCallbackQueue}),it("onPartialReply: extracts delta from cumulative snapshot and appendBody's the suffix",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"Hello"}),t.replyOptions.onPartialReply({text:"Hello, world"}),t.replyOptions.onPartialReply({text:"Hello, world!"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"Hello"},{type:"appendBody",chunk:", world"},{type:"appendBody",chunk:"!"}]),expect(e.body).toBe("Hello, world!")}),it("onPartialReply: drops shorter-prefix snapshots (runtime echo of older state)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"abcdef"}),t.replyOptions.onPartialReply({text:"abc"}),t.replyOptions.onPartialReply({text:"abcdefg"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"abcdef"},{type:"appendBody",chunk:"g"}])}),it("onPartialReply: treats non-prefix snapshot as a reset (setBody replace, NOT append)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"alpha"}),t.replyOptions.onPartialReply({text:"beta"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"alpha"},{type:"setBody",text:"beta"}]),expect(e.body).toBe("beta")}),it("sendFinalReply: setBody to the canonical full text",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"partial..."}),t.dispatcher.sendFinalReply({text:"canonical final body"}),await n.waitForIdle(),expect(e.calls.at(-1)).toEqual({type:"setBody",text:"canonical final body"}),expect(e.body).toBe("canonical final body"),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:0,final:1})}),it("sendBlockReply: setBody for finalized block (block_reply semantics)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.dispatcher.sendBlockReply({text:"block contents"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"setBody",text:"block contents"}]),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:1,final:0})}),it("onReasoningStream: appends cumulative-snapshot deltas onto the agent_reasoning row (NOT the reply row)",async()=>{const o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onReasoningStream({text:"thinking step 1"}),o.replyOptions.onReasoningStream({text:"thinking step 1 and 2"}),o.replyOptions.onReasoningEnd(),await n.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"thinking step 1"},{type:"appendBody",chunk:" and 2"}]),expect(t.body).toBe("thinking step 1 and 2"),expect(e.calls).toEqual([{type:"appendThinking",text:"thinking step 1 and 2",state:"done"}])}),it("onReasoningStream → onReasoningEnd → onReasoningStream: second burst appends after a newline; two reply thinking parts",async()=>{const o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onReasoningStream({text:"first"}),o.replyOptions.onReasoningEnd(),o.replyOptions.onReasoningStream({text:"second"}),o.replyOptions.onReasoningEnd(),await n.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"first"},{type:"appendBody",chunk:"\n"},{type:"appendBody",chunk:"second"}]),expect(t.body).toBe("first\nsecond"),expect(e.calls).toEqual([{type:"appendThinking",text:"first",state:"done"},{type:"appendThinking",text:"second",state:"done"}])}),it("onReasoningStream: non-prefix branch mid-burst replaces the row (committed prefix + new text)",async()=>{const o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onReasoningStream({text:"alpha"}),o.replyOptions.onReasoningStream({text:"beta gamma"}),await n.waitForIdle(),expect(t.calls).toEqual([{type:"appendBody",chunk:"alpha"},{type:"setBody",text:"beta gamma"}]),expect(t.body).toBe("beta gamma")}),it("onReasoningStream without an openReasoningStream factory: reasoning chunks are dropped",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onReasoningStream({text:"ignored"}),t.replyOptions.onReasoningEnd(),await n.waitForIdle(),expect(e.calls).toEqual([])}),it("onToolStart followed by sendToolResult: associates them via stack-popped tool_use_id",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onToolStart({name:"calc"}),t.dispatcher.sendToolResult({text:"42"}),await n.waitForIdle();const o=e.calls[0],s=e.calls[1];expect(o.type).toBe("appendToolUse"),expect(s.type).toBe("appendToolResult"),"appendToolUse"===o.type&&"appendToolResult"===s.type&&(expect(s.toolUseId).toBe(o.id),expect(s.content).toBe("42")),expect(t.dispatcher.getQueuedCounts()).toEqual({tool:1,block:0,final:0})}),it("sendToolResult without a preceding onToolStart: synthesizes a tool_use_id",async()=>{createImReplySink(buildDeps(e,a.signal,n)).dispatcher.sendToolResult({text:"orphan",isError:!0}),await n.waitForIdle(),expect(e.calls).toHaveLength(1);const t=e.calls[0];expect(t.type).toBe("appendToolResult"),"appendToolResult"===t.type&&(expect(t.toolUseId).toMatch(/^tool_im_req_test_1$/),expect(t.content).toBe("orphan"),expect(t.isError).toBe(!0))}),it("preserves callback order across interleaved partial / reasoning / tool events",async()=>{const o=createImReplySink(buildDeps(e,a.signal,n,t));o.replyOptions.onPartialReply({text:"A"}),o.replyOptions.onReasoningStream({text:"think 1"}),o.replyOptions.onPartialReply({text:"AB"}),o.replyOptions.onToolStart({name:"tool1"}),o.dispatcher.sendToolResult({text:"result1"}),o.replyOptions.onPartialReply({text:"ABC"}),o.replyOptions.onReasoningEnd(),o.dispatcher.sendFinalReply({text:"ABC final"}),await n.waitForIdle();const s=e.calls.map(e=>e.type);expect(s).toEqual(["appendBody","appendBody","appendToolUse","appendToolResult","appendBody","appendThinking","setBody"]),expect(t.calls).toEqual([{type:"appendBody",chunk:"think 1"}])}),it("aborted signal suppresses ALL further dispatcher / replyOptions calls",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"before"}),await n.waitForIdle(),a.abort("test_abort"),t.replyOptions.onPartialReply({text:"before-after"}),t.dispatcher.sendFinalReply({text:"should-not-fire"}),t.replyOptions.onReasoningStream({text:"blocked"}),t.replyOptions.onToolStart({name:"blocked"}),t.dispatcher.sendToolResult({text:"blocked"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"before"}])}),it("markComplete suppresses subsequent callbacks (idempotent terminal)",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.replyOptions.onPartialReply({text:"live"}),t.dispatcher.markComplete(),t.replyOptions.onPartialReply({text:"live-after"}),t.dispatcher.sendFinalReply({text:"after-complete"}),await n.waitForIdle(),expect(e.calls).toEqual([{type:"appendBody",chunk:"live"}])}),it("getQueuedCounts returns a snapshot copy (mutation does not leak)",()=>{const t=createImReplySink(buildDeps(e,a.signal,n));t.dispatcher.getQueuedCounts().tool=999,expect(t.dispatcher.getQueuedCounts()).toEqual({tool:0,block:0,final:0})}),it("when the stream is already terminated, callbacks are silently dropped",async()=>{const t=createImReplySink(buildDeps(e,a.signal,n));e.isTerminated=!0,t.replyOptions.onPartialReply({text:"dropped"}),t.dispatcher.sendFinalReply({text:"dropped"}),await n.waitForIdle(),expect(e.calls).toEqual([])}),it("sink callback throwing inside guarded does not poison the queue (logged as warn)",async()=>{const t=buildDeps(e,a.signal,n),o=createImReplySink(t),s=e.appendBody.bind(e);let l=!0;e.appendBody=e=>{if(l)throw l=!1,new Error("synthetic appendBody failure");return s(e)},o.replyOptions.onPartialReply({text:"trigger"}),o.replyOptions.onPartialReply({text:"trigger ok"}),await n.waitForIdle(),expect(e.body).toBe(" ok"),expect(t.logger.lines.some(e=>e.includes("[im-sink] partial mapping failed"))).toBe(!0)})});
@@ -0,0 +1 @@
1
+ export class PromptRunStore{byInboundMessageId=new Map;size(){return this.byInboundMessageId.size}register(e,r){if(this.byInboundMessageId.has(e)){try{r.abortController.abort("duplicate_inbound_message_id")}catch{}return!1}return this.byInboundMessageId.set(e,r),!0}abort(e,r){const n=this.byInboundMessageId.get(e);if(!n)return{ok:!1,reason:"not_found"};if(n.abortController.signal.aborted)return{ok:!1,reason:"already_aborted",requestId:n.requestId,channelId:n.channelId};try{n.abortController.abort(r)}catch{}return{ok:!0,requestId:n.requestId,channelId:n.channelId}}unregister(e){this.byInboundMessageId.delete(e)}snapshot(){return Array.from(this.byInboundMessageId.entries())}}
@@ -0,0 +1 @@
1
+ import{describe,it,expect,beforeEach}from"vitest";import{PromptRunStore}from"./prompt-run-store.js";const makeEntry=(e={})=>({requestId:"im_req_test_001",channelId:"ch_test_001",abortController:new AbortController,startedAt:Date.now(),...e});describe("PromptRunStore",()=>{let e;beforeEach(()=>{e=new PromptRunStore}),it("starts empty",()=>{expect(e.size()).toBe(0),expect(e.snapshot()).toEqual([])}),it("register adds an entry and reports size correctly",()=>{const t=makeEntry();expect(e.register("msg_001",t)).toBe(!0),expect(e.size()).toBe(1),expect(e.snapshot()).toEqual([["msg_001",t]])}),it("register rejects a duplicate inboundMessageId and aborts the duplicate's controller",()=>{const t=makeEntry({requestId:"req_1"}),r=makeEntry({requestId:"req_2"});expect(e.register("msg_dup",t)).toBe(!0),expect(e.register("msg_dup",r)).toBe(!1),expect(e.snapshot()).toEqual([["msg_dup",t]]),expect(r.abortController.signal.aborted).toBe(!0),expect(r.abortController.signal.reason).toBe("duplicate_inbound_message_id"),expect(t.abortController.signal.aborted).toBe(!1)}),it("abort triggers the AbortSignal and returns ok=true with metadata",()=>{const t=makeEntry({requestId:"req_abort_ok",channelId:"ch_abort_ok"});e.register("msg_abort_ok",t);const r=e.abort("msg_abort_ok","chat_cancel:user_request");expect(r).toEqual({ok:!0,requestId:"req_abort_ok",channelId:"ch_abort_ok"}),expect(t.abortController.signal.aborted).toBe(!0),expect(t.abortController.signal.reason).toBe("chat_cancel:user_request")}),it("abort on unknown inboundMessageId returns not_found and is a no-op",()=>{const t=e.abort("msg_unknown","whatever");expect(t).toEqual({ok:!1,reason:"not_found"})}),it("abort on an already-aborted entry returns already_aborted",()=>{const t=makeEntry({requestId:"req_2x"});t.abortController.abort("first_call"),e.register("msg_2x",t);const r=e.abort("msg_2x","second_call");expect(r).toEqual({ok:!1,reason:"already_aborted",requestId:"req_2x",channelId:t.channelId}),expect(t.abortController.signal.reason).toBe("first_call")}),it("unregister removes the entry and is safe to call twice",()=>{const t=makeEntry();e.register("msg_drop",t),expect(e.size()).toBe(1),e.unregister("msg_drop"),expect(e.size()).toBe(0),e.unregister("msg_drop"),expect(e.size()).toBe(0)}),it("unregister of unknown id is a silent no-op",()=>{expect(()=>e.unregister("msg_ghost")).not.toThrow(),expect(e.size()).toBe(0)}),it("supports multiple concurrent entries with independent abort",()=>{const t=makeEntry({requestId:"req_a",channelId:"ch_a"}),r=makeEntry({requestId:"req_b",channelId:"ch_b"}),o=makeEntry({requestId:"req_c",channelId:"ch_c"});e.register("msg_a",t),e.register("msg_b",r),e.register("msg_c",o),expect(e.size()).toBe(3),expect(e.abort("msg_b","cancel_b").ok).toBe(!0),expect(t.abortController.signal.aborted).toBe(!1),expect(r.abortController.signal.aborted).toBe(!0),expect(o.abortController.signal.aborted).toBe(!1),e.unregister("msg_b"),expect(e.size()).toBe(2),expect(e.snapshot().map(([e])=>e).sort()).toEqual(["msg_a","msg_c"])}),it("snapshot returns a defensive array copy (mutation does not affect store)",()=>{e.register("msg_snap",makeEntry());e.snapshot().length=0,expect(e.size()).toBe(1)})});
@@ -0,0 +1 @@
1
+ const DEFAULT_ORIGINATING_CHANNEL="beeos-claw",DEFAULT_ORIGINATING_TARGET="main",DEFAULT_ORIGINATING_ACCOUNT_ID="main",isRecord=o=>"object"==typeof o&&null!==o&&!Array.isArray(o),assertKnownReplyProtocol=o=>{if("acp"!==o&&"im"!==o)throw new Error(`unsupported reply route protocol=${o}`)};export const hasRuntimeReplyApi=o=>"function"==typeof o?.channel?.reply?.dispatchReplyFromConfig;export const buildFinalizedMsgContext=(o,e)=>{assertKnownReplyProtocol(e.protocol);const r=e.originatingChannel??"beeos-claw",t=e.originatingTo??"main",n=e.accountId??"main";return{Body:o.message,BodyForAgent:o.agentMessage,From:`${r}:${t}`,To:t,SessionKey:e.sessionId,Surface:r,Provider:r,OriginatingChannel:r,OriginatingTo:t,AccountId:n,CommandAuthorized:!0}};export const resolveReplySinkForRoute=(o,e)=>{assertKnownReplyProtocol(o.protocol);const r=e[o.protocol];if(!r)throw new Error(`runtime reply sink unavailable protocol=${o.protocol} requestId=${o.requestId}`);return r};const resolveRuntimeDispatchConfig=(o,e)=>{try{const e=o.config?.loadConfig?.();if(isRecord(e))return e}catch{}return e};export const dispatchRuntimeReplyWithRoute=async o=>{const e=o.runtime.channel?.reply?.dispatchReplyFromConfig;if("function"!=typeof e)throw new Error(`runtime reply API unavailable protocol=${o.replyRoute.protocol} requestId=${o.replyRoute.requestId}`);const r=resolveReplySinkForRoute(o.replyRoute,o.replySinks);return o.onDispatchInvoked?.(),e({ctx:buildFinalizedMsgContext(o.preparedPrompt,o.replyRoute),cfg:resolveRuntimeDispatchConfig(o.runtime,o.pluginConfig),dispatcher:r.dispatcher,replyOptions:r.replyOptions})};
@@ -0,0 +1 @@
1
+ import{describe,it,expect,vi}from"vitest";import{hasRuntimeReplyApi,buildFinalizedMsgContext,resolveReplySinkForRoute,dispatchRuntimeReplyWithRoute}from"./reply-router.js";const noopSink=()=>({dispatcher:{sendToolResult:()=>!0,sendBlockReply:()=>!0,sendFinalReply:()=>!0,waitForIdle:async()=>{},getQueuedCounts:()=>({tool:0,block:0,final:0}),markComplete:()=>{}},replyOptions:void 0}),okResult={counts:{tool:0,block:0,final:1}},baseRoute=(e={})=>({protocol:"im",requestId:"req_test_001",sessionId:"agent:main:test-session",...e}),basePrompt=(e={})=>({message:"hello world",agentMessage:"hello world",...e});describe("hasRuntimeReplyApi",()=>{it("returns true when runtime exposes a callable dispatchReplyFromConfig",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}})).toBe(!0)}),it("returns false when runtime is undefined",()=>{expect(hasRuntimeReplyApi(void 0)).toBe(!1)}),it("returns false when channel.reply is missing",()=>{expect(hasRuntimeReplyApi({})).toBe(!1),expect(hasRuntimeReplyApi({channel:{}})).toBe(!1),expect(hasRuntimeReplyApi({channel:{reply:{}}})).toBe(!1)}),it("returns false when dispatchReplyFromConfig is present but not a function",()=>{expect(hasRuntimeReplyApi({channel:{reply:{dispatchReplyFromConfig:"not-a-function"}}})).toBe(!1)})}),describe("buildFinalizedMsgContext",()=>{it("uses beeos-claw defaults when route doesn't override",()=>{const e=buildFinalizedMsgContext(basePrompt(),baseRoute());expect(e).toEqual({Body:"hello world",BodyForAgent:"hello world",From:"beeos-claw:main",To:"main",SessionKey:"agent:main:test-session",Surface:"beeos-claw",Provider:"beeos-claw",OriginatingChannel:"beeos-claw",OriginatingTo:"main",AccountId:"main",CommandAuthorized:!0})}),it("honors caller-supplied originatingChannel / originatingTo / accountId",()=>{const e=buildFinalizedMsgContext(basePrompt({message:"hi",agentMessage:"agent-hi"}),baseRoute({originatingChannel:"custom-channel",originatingTo:"thread-42",accountId:"user-7"}));expect(e.From).toBe("custom-channel:thread-42"),expect(e.To).toBe("thread-42"),expect(e.OriginatingChannel).toBe("custom-channel"),expect(e.OriginatingTo).toBe("thread-42"),expect(e.AccountId).toBe("user-7"),expect(e.BodyForAgent).toBe("agent-hi")}),it("accepts both 'acp' and 'im' protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"acp"}))).not.toThrow(),expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"im"}))).not.toThrow()}),it("rejects unsupported protocols",()=>{expect(()=>buildFinalizedMsgContext(basePrompt(),baseRoute({protocol:"websocket"}))).toThrow(/unsupported reply route protocol=websocket/)})}),describe("resolveReplySinkForRoute",()=>{it("returns the matching protocol sink",()=>{const e=noopSink(),t=noopSink(),o={acp:e,im:t};expect(resolveReplySinkForRoute(baseRoute({protocol:"acp"}),o)).toBe(e),expect(resolveReplySinkForRoute(baseRoute({protocol:"im"}),o)).toBe(t)}),it("throws when the matching sink is missing",()=>{const e={acp:noopSink()};expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"im",requestId:"req_missing"}),e)).toThrow(/runtime reply sink unavailable protocol=im requestId=req_missing/)}),it("rejects unsupported protocols",()=>{expect(()=>resolveReplySinkForRoute(baseRoute({protocol:"xyz"}),{im:noopSink()})).toThrow(/unsupported reply route protocol=xyz/)})}),describe("dispatchRuntimeReplyWithRoute",()=>{it("dispatches via the matching sink and returns the runtime result",async()=>{const e=noopSink(),t=[],o={channel:{reply:{dispatchReplyFromConfig:async e=>(t.push(e),okResult)}}},n=await dispatchRuntimeReplyWithRoute({runtime:o,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt({message:"test prompt"}),replyRoute:baseRoute({protocol:"im",requestId:"req_ok"}),replySinks:{im:e}});expect(n).toBe(okResult),expect(t).toHaveLength(1);const i=t[0];expect(i.ctx.Body).toBe("test prompt"),expect(i.ctx.OriginatingChannel).toBe("beeos-claw"),expect(i.dispatcher).toBe(e.dispatcher),expect(i.cfg).toEqual({fromPluginConfig:!0})}),it("prefers runtime.config.loadConfig() over pluginConfig",async()=>{const e=[],t={config:{loadConfig:()=>({fromRuntime:!0})},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromRuntime:!0})}),it("falls back to pluginConfig when runtime.config.loadConfig throws",async()=>{const e=[],t={config:{loadConfig:()=>{throw new Error("load failed")}},channel:{reply:{dispatchReplyFromConfig:async t=>(e.push(t),okResult)}}};await dispatchRuntimeReplyWithRoute({runtime:t,pluginConfig:{fromPluginConfig:!0},preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}}),expect(e[0].cfg).toEqual({fromPluginConfig:!0})}),it("calls onDispatchInvoked exactly once, before awaiting the dispatch promise",async()=>{const e=vi.fn();let t=!1;const o={channel:{reply:{dispatchReplyFromConfig:vi.fn(async()=>(expect(e).toHaveBeenCalledTimes(1),t=!0,okResult))}}};await dispatchRuntimeReplyWithRoute({runtime:o,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()},onDispatchInvoked:e}),expect(e).toHaveBeenCalledTimes(1),expect(t).toBe(!0)}),it("throws when runtime reply API is missing",async()=>{await expect(dispatchRuntimeReplyWithRoute({runtime:{},preparedPrompt:basePrompt(),replyRoute:baseRoute({requestId:"req_no_api"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply API unavailable protocol=im requestId=req_no_api/)}),it("propagates runtime dispatch errors verbatim",async()=>{const e=new Error("LLM exploded"),t={channel:{reply:{dispatchReplyFromConfig:async()=>{throw e}}}};await expect(dispatchRuntimeReplyWithRoute({runtime:t,preparedPrompt:basePrompt(),replyRoute:baseRoute(),replySinks:{im:noopSink()}})).rejects.toBe(e)}),it("throws when route.protocol has no matching sink",async()=>{const e={channel:{reply:{dispatchReplyFromConfig:async()=>okResult}}};await expect(dispatchRuntimeReplyWithRoute({runtime:e,preparedPrompt:basePrompt(),replyRoute:baseRoute({protocol:"acp",requestId:"req_no_sink"}),replySinks:{im:noopSink()}})).rejects.toThrow(/runtime reply sink unavailable protocol=acp requestId=req_no_sink/)})});
@@ -2,7 +2,7 @@
2
2
  "id": "beeos-claw",
3
3
  "name": "beeos-claw",
4
4
  "description": "BeeOS platform bridge plugin for OpenClaw. Provides file upload/download, ACP bridge, and observability.",
5
- "version": "0.1.0",
5
+ "version": "0.5.9",
6
6
  "activation": {
7
7
  "onStartup": true
8
8
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beeos-claw",
3
- "version": "0.5.16",
3
+ "version": "0.5.17-staging.0",
4
4
  "description": "OpenClaw plugin that bridges the BeeOS platform with the local OpenClaw Gateway, providing file transfer, ACP bridge, and observability.",
5
5
  "license": "MIT",
6
6
  "author": "BeeOS <dev@beeos.ai>",
@@ -41,7 +41,7 @@
41
41
  "dependencies": {
42
42
  "@beeos-ai/beeos-types": "^1.0.0",
43
43
  "@beeos-ai/bridge-client": "^0.1.0",
44
- "@beeos-ai/message-sdk": "^1.0.0",
44
+ "@beeos-ai/message-sdk": "^1.1.0",
45
45
  "adm-zip": "^0.5.17",
46
46
  "ws": "^8.18.0"
47
47
  },
@@ -1 +0,0 @@
1
- export function parseMentionsParam(e){return Array.isArray(e)?e.filter(e=>"object"==typeof e&&null!==e&&"string"==typeof e.agentId&&"string"==typeof e.name):[]}export function buildMentionInstruction(e){if(!e.length)return"";return["[A2A Delegation Request]","The user explicitly @mentioned the following agent(s). You MUST delegate to them using beeos_call_agent.",...e.map(e=>`- Agent "${e.name}" → agentId: "${e.agentId}"`),"IMPORTANT: Always pass the exact agentId (UUID) shown above, NOT the agent name.","Pass the user's message (excluding the @mention text) as the task message.",""].join("\n")}export function resolveAgentIdFromMentions(e,t){if(!e.length)return;const n=t.toLowerCase(),a=e.find(e=>e.name.toLowerCase()===n);return a?.agentId}