diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b51ba..99301e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## 0.16.0 — 2026-07-01 + +A hardening and quality release: a Windows fix, ~20 robustness fixes across process lifecycle and persistence, dashboard performance work, a batch of UX improvements, and a large internal restructuring — with 79 new tests (331 total). + +**Windows: ENAMETOOLONG fixed** (thanks @zhaoming-mike!) +- The Claude adapter now passes the prompt via stdin instead of argv, so one-shot runs (heartbeats, scheduled tasks, Wiki Keeper) no longer fail on Windows' 32,767-character command-line limit. Also makes long prompts more robust on macOS/Linux. + +**Robustness** +- Codex follow-up messages queued during a turn are no longer silently lost if the next turn fails to start. +- Stdout buffers are capped (10MB) in both chat and one-shot runs — a runaway process can no longer exhaust memory. +- The chat "working" spinner can no longer get stuck when a CLI process dies between turns. +- Heartbeat overlap guard now holds until the run actually finishes (it previously released on enqueue, so overlapping heartbeat runs were possible). +- One hung vault write can no longer wedge all subsequent memory captures (10s per-capture timeout). +- Corrupted `permissions.json` or agent sidecar files now log an error and flag a validation issue in the dashboard instead of silently running the agent with empty permissions. +- CLI output that fails to parse is now logged with context instead of showing "(no output)" with no clue — makes CLI version drift debuggable. +- Reference validation re-runs after create/update/delete, so deleting a skill immediately flags agents that still reference it. +- Working-memory files with a newer schema version are left untouched instead of being clobbered. +- Legacy memory migration and Slack's send queue no longer race under concurrency; channel shutdown drains in-flight turns. +- MCP OAuth callback server reliably frees its port (previously a lingering keep-alive connection could cause "address already in use" on the next login). +- Stale MCP temp files and Codex overlays are swept on plugin load; temp names are now collision-proof UUIDs. +- Reflection run failures now show a Notice (all other run types already did). + +**UX** +- Deleting a task, skill, or channel now asks for confirmation (parity with agent deletion). +- Search: shows "No results" instead of nothing, a "Showing 10 of N" footer when truncated, and is debounced. +- All create/edit forms disable their submit button while saving (no more accidental double-submits). +- Empty states have Create buttons; a dismissible welcome card appears on a pristine fleet. +- Agent cards show heartbeat state, schedule, next run time, and a quick pause/resume toggle. +- Task scheduling is a single Immediate / Recurring / One-time selector (saved file format unchanged). +- Chat error bubbles include recovery hints for common failures (context overflow, rate limit, auth, timeout, missing CLI). +- Switching an agent between Claude and Codex explains how permission modes map. +- Run success Notices report how many memory facts were captured. + +**Performance** +- Agents page no longer refetches runs per card (was O(n²) with fleet size). +- Live run output batches dashboard updates (~100ms) instead of re-rendering per stdout chunk. +- Run logs are parse-cached by mtime; fleet status is cached; name lookups are indexed; running-card timers share one ticker. + +**Internal** +- `FleetRepository` decomposed into focused store modules (`src/repository/`); the dashboard view split into form and page modules (`src/views/forms/`, `src/views/pages/`). Shared prompt assembly guarantees chat and one-shot runs build identical context. Channel adapters share text-splitting and backoff helpers. 221 lines of dead CSS removed. + ## 0.15.0 — 2026-06-21 Accurate cost tracking, live follow-ups in channels, and Discord docs. diff --git a/defaults/skills/agent-fleet-system/tools.md b/defaults/skills/agent-fleet-system/tools.md index 1aacfa6..eee9fa5 100644 --- a/defaults/skills/agent-fleet-system/tools.md +++ b/defaults/skills/agent-fleet-system/tools.md @@ -240,6 +240,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: `@agent-name` ro - Agents with `approval_required` set cannot be bound to a channel - Multi-agent routing: type `@agent-name: message` to switch agents, or use `/agents` for interactive picker - Obsidian must be running for channels to work — when closed, bots go offline +- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn — matching the in-app chat. Each injected follow-up gets its own reply; `[REMEMBER]` blocks are stripped from channel replies. ## Creating a Task diff --git a/main.js b/main.js index b847884..1255ba4 100644 --- a/main.js +++ b/main.js @@ -1,10 +1,10 @@ -"use strict";var rl=Object.create;var $s=Object.defineProperty;var il=Object.getOwnPropertyDescriptor;var ol=Object.getOwnPropertyNames;var ll=Object.getPrototypeOf,cl=Object.prototype.hasOwnProperty;var Ve=(r,t)=>()=>(t||r((t={exports:{}}).exports,t),t.exports),dl=(r,t)=>{for(var e in t)$s(r,e,{get:t[e],enumerable:!0})},ar=(r,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of ol(t))!cl.call(r,n)&&n!==e&&$s(r,n,{get:()=>t[n],enumerable:!(s=il(t,n))||s.enumerable});return r};var Ye=(r,t,e)=>(e=r!=null?rl(ll(r)):{},ar(t||!r||!r.__esModule?$s(e,"default",{value:r,enumerable:!0}):e,r)),hl=r=>ar($s({},"__esModule",{value:!0}),r);var gt=Ve((Sp,Pi)=>{"use strict";var Ai=["nodebuffer","arraybuffer","fragments"],Ei=typeof Blob<"u";Ei&&Ai.push("blob");Pi.exports={BINARY_TYPES:Ai,CLOSE_TIMEOUT:3e4,EMPTY_BUFFER:Buffer.alloc(0),GUID:"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",hasBlob:Ei,kForOnEventAttribute:Symbol("kIsForOnEventAttribute"),kListener:Symbol("kListener"),kStatusCode:Symbol("status-code"),kWebSocket:Symbol("websocket"),NOOP:()=>{}}});var _s=Ve((Cp,xn)=>{"use strict";var{EMPTY_BUFFER:xc}=gt(),Ca=Buffer[Symbol.species];function Sc(r,t){if(r.length===0)return xc;if(r.length===1)return r[0];let e=Buffer.allocUnsafe(t),s=0;for(let n=0;n{"use strict";var Ii=Symbol("kDone"),_a=Symbol("kRun"),Aa=class{constructor(t){this[Ii]=()=>{this.pending--,this[_a]()},this.concurrency=t||1/0,this.jobs=[],this.pending=0}add(t){this.jobs.push(t),this[_a]()}[_a](){if(this.pending!==this.concurrency&&this.jobs.length){let t=this.jobs.shift();this.pending++,t(this[Ii])}}};Mi.exports=Aa});var Qt=Ve((_p,Bi)=>{"use strict";var As=require("zlib"),Fi=_s(),Tc=Li(),{kStatusCode:Oi}=gt(),_c=Buffer[Symbol.species],Ac=Buffer.from([0,0,255,255]),Cn=Symbol("permessage-deflate"),yt=Symbol("total-length"),Jt=Symbol("callback"),St=Symbol("buffers"),Xt=Symbol("error"),Sn,Ea=class{constructor(t){if(this._options=t||{},this._threshold=this._options.threshold!==void 0?this._options.threshold:1024,this._maxPayload=this._options.maxPayload|0,this._isServer=!!this._options.isServer,this._deflate=null,this._inflate=null,this.params=null,!Sn){let e=this._options.concurrencyLimit!==void 0?this._options.concurrencyLimit:10;Sn=new Tc(e)}}static get extensionName(){return"permessage-deflate"}offer(){let t={};return this._options.serverNoContextTakeover&&(t.server_no_context_takeover=!0),this._options.clientNoContextTakeover&&(t.client_no_context_takeover=!0),this._options.serverMaxWindowBits&&(t.server_max_window_bits=this._options.serverMaxWindowBits),this._options.clientMaxWindowBits?t.client_max_window_bits=this._options.clientMaxWindowBits:this._options.clientMaxWindowBits==null&&(t.client_max_window_bits=!0),t}accept(t){return t=this.normalizeParams(t),this.params=this._isServer?this.acceptAsServer(t):this.acceptAsClient(t),this.params}cleanup(){if(this._inflate&&(this._inflate.close(),this._inflate=null),this._deflate){let t=this._deflate[Jt];this._deflate.close(),this._deflate=null,t&&t(new Error("The deflate stream was closed while data was being processed"))}}acceptAsServer(t){let e=this._options,s=t.find(n=>!(e.serverNoContextTakeover===!1&&n.server_no_context_takeover||n.server_max_window_bits&&(e.serverMaxWindowBits===!1||typeof e.serverMaxWindowBits=="number"&&e.serverMaxWindowBits>n.server_max_window_bits)||typeof e.clientMaxWindowBits=="number"&&!n.client_max_window_bits));if(!s)throw new Error("None of the extension offers can be accepted");return e.serverNoContextTakeover&&(s.server_no_context_takeover=!0),e.clientNoContextTakeover&&(s.client_no_context_takeover=!0),typeof e.serverMaxWindowBits=="number"&&(s.server_max_window_bits=e.serverMaxWindowBits),typeof e.clientMaxWindowBits=="number"?s.client_max_window_bits=e.clientMaxWindowBits:(s.client_max_window_bits===!0||e.clientMaxWindowBits===!1)&&delete s.client_max_window_bits,s}acceptAsClient(t){let e=t[0];if(this._options.clientNoContextTakeover===!1&&e.client_no_context_takeover)throw new Error('Unexpected parameter "client_no_context_takeover"');if(!e.client_max_window_bits)typeof this._options.clientMaxWindowBits=="number"&&(e.client_max_window_bits=this._options.clientMaxWindowBits);else if(this._options.clientMaxWindowBits===!1||typeof this._options.clientMaxWindowBits=="number"&&e.client_max_window_bits>this._options.clientMaxWindowBits)throw new Error('Unexpected or invalid parameter "client_max_window_bits"');return e}normalizeParams(t){return t.forEach(e=>{Object.keys(e).forEach(s=>{let n=e[s];if(n.length>1)throw new Error(`Parameter "${s}" must have only a single value`);if(n=n[0],s==="client_max_window_bits"){if(n!==!0){let a=+n;if(!Number.isInteger(a)||a<8||a>15)throw new TypeError(`Invalid value for parameter "${s}": ${n}`);n=a}else if(!this._isServer)throw new TypeError(`Invalid value for parameter "${s}": ${n}`)}else if(s==="server_max_window_bits"){let a=+n;if(!Number.isInteger(a)||a<8||a>15)throw new TypeError(`Invalid value for parameter "${s}": ${n}`);n=a}else if(s==="client_no_context_takeover"||s==="server_no_context_takeover"){if(n!==!0)throw new TypeError(`Invalid value for parameter "${s}": ${n}`)}else throw new Error(`Unknown parameter "${s}"`);e[s]=n})}),t}decompress(t,e,s){Sn.add(n=>{this._decompress(t,e,(a,i)=>{n(),s(a,i)})})}compress(t,e,s){Sn.add(n=>{this._compress(t,e,(a,i)=>{n(),s(a,i)})})}_decompress(t,e,s){let n=this._isServer?"client":"server";if(!this._inflate){let a=`${n}_max_window_bits`,i=typeof this.params[a]!="number"?As.Z_DEFAULT_WINDOWBITS:this.params[a];this._inflate=As.createInflateRaw({...this._options.zlibInflateOptions,windowBits:i}),this._inflate[Cn]=this,this._inflate[yt]=0,this._inflate[St]=[],this._inflate.on("error",Pc),this._inflate.on("data",Ni)}this._inflate[Jt]=s,this._inflate.write(t),e&&this._inflate.write(Ac),this._inflate.flush(()=>{let a=this._inflate[Xt];if(a){this._inflate.close(),this._inflate=null,s(a);return}let i=Fi.concat(this._inflate[St],this._inflate[yt]);this._inflate._readableState.endEmitted?(this._inflate.close(),this._inflate=null):(this._inflate[yt]=0,this._inflate[St]=[],e&&this.params[`${n}_no_context_takeover`]&&this._inflate.reset()),s(null,i)})}_compress(t,e,s){let n=this._isServer?"server":"client";if(!this._deflate){let a=`${n}_max_window_bits`,i=typeof this.params[a]!="number"?As.Z_DEFAULT_WINDOWBITS:this.params[a];this._deflate=As.createDeflateRaw({...this._options.zlibDeflateOptions,windowBits:i}),this._deflate[yt]=0,this._deflate[St]=[],this._deflate.on("data",Ec)}this._deflate[Jt]=s,this._deflate.write(t),this._deflate.flush(As.Z_SYNC_FLUSH,()=>{if(!this._deflate)return;let a=Fi.concat(this._deflate[St],this._deflate[yt]);e&&(a=new _c(a.buffer,a.byteOffset,a.length-4)),this._deflate[Jt]=null,this._deflate[yt]=0,this._deflate[St]=[],e&&this.params[`${n}_no_context_takeover`]&&this._deflate.reset(),s(null,a)})}};Bi.exports=Ea;function Ec(r){this[St].push(r),this[yt]+=r.length}function Ni(r){if(this[yt]+=r.length,this[Cn]._maxPayload<1||this[yt]<=this[Cn]._maxPayload){this[St].push(r);return}this[Xt]=new RangeError("Max payload size exceeded"),this[Xt].code="WS_ERR_UNSUPPORTED_MESSAGE_LENGTH",this[Xt][Oi]=1009,this.removeListener("data",Ni),this.reset()}function Pc(r){if(this[Cn]._inflate=null,this[Xt]){this[Jt](this[Xt]);return}r[Oi]=1007,this[Jt](r)}});var Zt=Ve((Ap,Tn)=>{"use strict";var{isUtf8:Ui}=require("buffer"),{hasBlob:Rc}=gt(),Dc=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,0];function Ic(r){return r>=1e3&&r<=1014&&r!==1004&&r!==1005&&r!==1006||r>=3e3&&r<=4999}function Pa(r){let t=r.length,e=0;for(;e=t||(r[e+1]&192)!==128||(r[e+2]&192)!==128||r[e]===224&&(r[e+1]&224)===128||r[e]===237&&(r[e+1]&224)===160)return!1;e+=3}else if((r[e]&248)===240){if(e+3>=t||(r[e+1]&192)!==128||(r[e+2]&192)!==128||(r[e+3]&192)!==128||r[e]===240&&(r[e+1]&240)===128||r[e]===244&&r[e+1]>143||r[e]>244)return!1;e+=4}else return!1;return!0}function Mc(r){return Rc&&typeof r=="object"&&typeof r.arrayBuffer=="function"&&typeof r.type=="string"&&typeof r.stream=="function"&&(r[Symbol.toStringTag]==="Blob"||r[Symbol.toStringTag]==="File")}Tn.exports={isBlob:Mc,isValidStatusCode:Ic,isValidUTF8:Pa,tokenChars:Dc};if(Ui)Tn.exports.isValidUTF8=function(r){return r.length<24?Pa(r):Ui(r)};else if(!process.env.WS_NO_UTF_8_VALIDATE)try{let r=require("utf-8-validate");Tn.exports.isValidUTF8=function(t){return t.length<32?Pa(t):r(t)}}catch{}});var La=Ve((Ep,Gi)=>{"use strict";var{Writable:Lc}=require("stream"),$i=Qt(),{BINARY_TYPES:Fc,EMPTY_BUFFER:ji,kStatusCode:Oc,kWebSocket:Nc}=gt(),{concat:Ra,toArrayBuffer:Bc,unmask:Uc}=_s(),{isValidStatusCode:$c,isValidUTF8:Wi}=Zt(),_n=Buffer[Symbol.species],et=0,Hi=1,qi=2,zi=3,Da=4,Ia=5,An=6,Ma=class extends Lc{constructor(t={}){super(),this._allowSynchronousEvents=t.allowSynchronousEvents!==void 0?t.allowSynchronousEvents:!0,this._binaryType=t.binaryType||Fc[0],this._extensions=t.extensions||{},this._isServer=!!t.isServer,this._maxBufferedChunks=t.maxBufferedChunks|0,this._maxFragments=t.maxFragments|0,this._maxPayload=t.maxPayload|0,this._skipUTF8Validation=!!t.skipUTF8Validation,this[Nc]=void 0,this._bufferedBytes=0,this._buffers=[],this._compressed=!1,this._payloadLength=0,this._mask=void 0,this._fragmented=0,this._masked=!1,this._fin=!1,this._opcode=0,this._totalPayloadLength=0,this._messageLength=0,this._fragments=[],this._errored=!1,this._loop=!1,this._state=et}_write(t,e,s){if(this._opcode===8&&this._state==et)return s();if(this._maxBufferedChunks>0&&this._buffers.length>=this._maxBufferedChunks){s(this.createError(RangeError,"Too many buffered chunks",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS"));return}this._bufferedBytes+=t.length,this._buffers.push(t),this.startLoop(s)}consume(t){if(this._bufferedBytes-=t,t===this._buffers[0].length)return this._buffers.shift();if(t=s.length?e.set(this._buffers.shift(),n):(e.set(new Uint8Array(s.buffer,s.byteOffset,t),n),this._buffers[0]=new _n(s.buffer,s.byteOffset+t,s.length-t)),t-=s.length}while(t>0);return e}startLoop(t){this._loop=!0;do switch(this._state){case et:this.getInfo(t);break;case Hi:this.getPayloadLength16(t);break;case qi:this.getPayloadLength64(t);break;case zi:this.getMask();break;case Da:this.getData(t);break;case Ia:case An:this._loop=!1;return}while(this._loop);this._errored||t()}getInfo(t){if(this._bufferedBytes<2){this._loop=!1;return}let e=this.consume(2);if((e[0]&48)!==0){let n=this.createError(RangeError,"RSV2 and RSV3 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_2_3");t(n);return}let s=(e[0]&64)===64;if(s&&!this._extensions[$i.extensionName]){let n=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");t(n);return}if(this._fin=(e[0]&128)===128,this._opcode=e[0]&15,this._payloadLength=e[1]&127,this._opcode===0){if(s){let n=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");t(n);return}if(!this._fragmented){let n=this.createError(RangeError,"invalid opcode 0",!0,1002,"WS_ERR_INVALID_OPCODE");t(n);return}this._opcode=this._fragmented}else if(this._opcode===1||this._opcode===2){if(this._fragmented){let n=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");t(n);return}this._compressed=s}else if(this._opcode>7&&this._opcode<11){if(!this._fin){let n=this.createError(RangeError,"FIN must be set",!0,1002,"WS_ERR_EXPECTED_FIN");t(n);return}if(s){let n=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");t(n);return}if(this._payloadLength>125||this._opcode===8&&this._payloadLength===1){let n=this.createError(RangeError,`invalid payload length ${this._payloadLength}`,!0,1002,"WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH");t(n);return}}else{let n=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");t(n);return}if(!this._fin&&!this._fragmented&&(this._fragmented=this._opcode),this._masked=(e[1]&128)===128,this._isServer){if(!this._masked){let n=this.createError(RangeError,"MASK must be set",!0,1002,"WS_ERR_EXPECTED_MASK");t(n);return}}else if(this._masked){let n=this.createError(RangeError,"MASK must be clear",!0,1002,"WS_ERR_UNEXPECTED_MASK");t(n);return}this._payloadLength===126?this._state=Hi:this._payloadLength===127?this._state=qi:this.haveLength(t)}getPayloadLength16(t){if(this._bufferedBytes<2){this._loop=!1;return}this._payloadLength=this.consume(2).readUInt16BE(0),this.haveLength(t)}getPayloadLength64(t){if(this._bufferedBytes<8){this._loop=!1;return}let e=this.consume(8),s=e.readUInt32BE(0);if(s>Math.pow(2,21)-1){let n=this.createError(RangeError,"Unsupported WebSocket frame: payload length > 2^53 - 1",!1,1009,"WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH");t(n);return}this._payloadLength=s*Math.pow(2,32)+e.readUInt32BE(4),this.haveLength(t)}haveLength(t){if(this._payloadLength&&this._opcode<8&&(this._totalPayloadLength+=this._payloadLength,this._totalPayloadLength>this._maxPayload&&this._maxPayload>0)){let e=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");t(e);return}this._masked?this._state=zi:this._state=Da}getMask(){if(this._bufferedBytes<4){this._loop=!1;return}this._mask=this.consume(4),this._state=Da}getData(t){let e=ji;if(this._payloadLength){if(this._bufferedBytes7){this.controlMessage(e,t);return}if(this._compressed){this._state=Ia,this.decompress(e,t);return}if(e.length){if(this._maxFragments>0&&this._fragments.length>=this._maxFragments){let s=this.createError(RangeError,"Too many message fragments",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS");t(s);return}this._messageLength=this._totalPayloadLength,this._fragments.push(e)}this.dataMessage(t)}decompress(t,e){this._extensions[$i.extensionName].decompress(t,this._fin,(n,a)=>{if(n)return e(n);if(a.length){if(this._messageLength+=a.length,this._messageLength>this._maxPayload&&this._maxPayload>0){let i=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");e(i);return}if(this._maxFragments>0&&this._fragments.length>=this._maxFragments){let i=this.createError(RangeError,"Too many message fragments",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS");e(i);return}this._fragments.push(a)}this.dataMessage(e),this._state===et&&this.startLoop(e)})}dataMessage(t){if(!this._fin){this._state=et;return}let e=this._messageLength,s=this._fragments;if(this._totalPayloadLength=0,this._messageLength=0,this._fragmented=0,this._fragments=[],this._opcode===2){let n;this._binaryType==="nodebuffer"?n=Ra(s,e):this._binaryType==="arraybuffer"?n=Bc(Ra(s,e)):this._binaryType==="blob"?n=new Blob(s):n=s,this._allowSynchronousEvents?(this.emit("message",n,!0),this._state=et):(this._state=An,setImmediate(()=>{this.emit("message",n,!0),this._state=et,this.startLoop(t)}))}else{let n=Ra(s,e);if(!this._skipUTF8Validation&&!Wi(n)){let a=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");t(a);return}this._state===Ia||this._allowSynchronousEvents?(this.emit("message",n,!1),this._state=et):(this._state=An,setImmediate(()=>{this.emit("message",n,!1),this._state=et,this.startLoop(t)}))}}controlMessage(t,e){if(this._opcode===8){if(t.length===0)this._loop=!1,this.emit("conclude",1005,ji),this.end();else{let s=t.readUInt16BE(0);if(!$c(s)){let a=this.createError(RangeError,`invalid status code ${s}`,!0,1002,"WS_ERR_INVALID_CLOSE_CODE");e(a);return}let n=new _n(t.buffer,t.byteOffset+2,t.length-2);if(!this._skipUTF8Validation&&!Wi(n)){let a=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");e(a);return}this._loop=!1,this.emit("conclude",s,n),this.end()}this._state=et;return}this._allowSynchronousEvents?(this.emit(this._opcode===9?"ping":"pong",t),this._state=et):(this._state=An,setImmediate(()=>{this.emit(this._opcode===9?"ping":"pong",t),this._state=et,this.startLoop(e)}))}createError(t,e,s,n,a){this._loop=!1,this._errored=!0;let i=new t(s?`Invalid WebSocket frame: ${e}`:e);return Error.captureStackTrace(i,this.createError),i.code=a,i[Oc]=n,i}};Gi.exports=Ma});var Na=Ve((Rp,Ki)=>{"use strict";var{Duplex:Pp}=require("stream"),{randomFillSync:jc}=require("crypto"),{types:{isUint8Array:Wc}}=require("util"),Vi=Qt(),{EMPTY_BUFFER:Hc,kWebSocket:qc,NOOP:zc}=gt(),{isBlob:es,isValidStatusCode:Gc}=Zt(),{mask:Yi,toBuffer:Mt}=_s(),tt=Symbol("kByteLength"),Vc=Buffer.alloc(4),En=8*1024,Lt,ts=En,lt=0,Yc=1,Kc=2,Fa=class r{constructor(t,e,s){this._extensions=e||{},s&&(this._generateMask=s,this._maskBuffer=Buffer.alloc(4)),this._socket=t,this._firstFragment=!0,this._compress=!1,this._bufferedBytes=0,this._queue=[],this._state=lt,this.onerror=zc,this[qc]=void 0}static frame(t,e){let s,n=!1,a=2,i=!1;e.mask&&(s=e.maskBuffer||Vc,e.generateMask?e.generateMask(s):(ts===En&&(Lt===void 0&&(Lt=Buffer.alloc(En)),jc(Lt,0,En),ts=0),s[0]=Lt[ts++],s[1]=Lt[ts++],s[2]=Lt[ts++],s[3]=Lt[ts++]),i=(s[0]|s[1]|s[2]|s[3])===0,a=6);let o;typeof t=="string"?(!e.mask||i)&&e[tt]!==void 0?o=e[tt]:(t=Buffer.from(t),o=t.length):(o=t.length,n=e.mask&&e.readOnly&&!i);let c=o;o>=65536?(a+=8,c=127):o>125&&(a+=2,c=126);let l=Buffer.allocUnsafe(n?o+a:a);return l[0]=e.fin?e.opcode|128:e.opcode,e.rsv1&&(l[0]|=64),l[1]=c,c===126?l.writeUInt16BE(o,2):c===127&&(l[2]=l[3]=0,l.writeUIntBE(o,4,6)),e.mask?(l[1]|=128,l[a-4]=s[0],l[a-3]=s[1],l[a-2]=s[2],l[a-1]=s[3],i?[l,t]:n?(Yi(t,s,l,a,o),[l]):(Yi(t,s,t,0,o),[l,t])):[l,t]}close(t,e,s,n){let a;if(t===void 0)a=Hc;else{if(typeof t!="number"||!Gc(t))throw new TypeError("First argument must be a valid error code number");if(e===void 0||!e.length)a=Buffer.allocUnsafe(2),a.writeUInt16BE(t,0);else{let o=Buffer.byteLength(e);if(o>123)throw new RangeError("The message must not be greater than 123 bytes");if(a=Buffer.allocUnsafe(2+o),a.writeUInt16BE(t,0),typeof e=="string")a.write(e,2);else if(Wc(e))a.set(e,2);else throw new TypeError("Second argument must be a string or a Uint8Array")}}let i={[tt]:a.length,fin:!0,generateMask:this._generateMask,mask:s,maskBuffer:this._maskBuffer,opcode:8,readOnly:!1,rsv1:!1};this._state!==lt?this.enqueue([this.dispatch,a,!1,i,n]):this.sendFrame(r.frame(a,i),n)}ping(t,e,s){let n,a;if(typeof t=="string"?(n=Buffer.byteLength(t),a=!1):es(t)?(n=t.size,a=!1):(t=Mt(t),n=t.length,a=Mt.readOnly),n>125)throw new RangeError("The data size must not be greater than 125 bytes");let i={[tt]:n,fin:!0,generateMask:this._generateMask,mask:e,maskBuffer:this._maskBuffer,opcode:9,readOnly:a,rsv1:!1};es(t)?this._state!==lt?this.enqueue([this.getBlobData,t,!1,i,s]):this.getBlobData(t,!1,i,s):this._state!==lt?this.enqueue([this.dispatch,t,!1,i,s]):this.sendFrame(r.frame(t,i),s)}pong(t,e,s){let n,a;if(typeof t=="string"?(n=Buffer.byteLength(t),a=!1):es(t)?(n=t.size,a=!1):(t=Mt(t),n=t.length,a=Mt.readOnly),n>125)throw new RangeError("The data size must not be greater than 125 bytes");let i={[tt]:n,fin:!0,generateMask:this._generateMask,mask:e,maskBuffer:this._maskBuffer,opcode:10,readOnly:a,rsv1:!1};es(t)?this._state!==lt?this.enqueue([this.getBlobData,t,!1,i,s]):this.getBlobData(t,!1,i,s):this._state!==lt?this.enqueue([this.dispatch,t,!1,i,s]):this.sendFrame(r.frame(t,i),s)}send(t,e,s){let n=this._extensions[Vi.extensionName],a=e.binary?2:1,i=e.compress,o,c;typeof t=="string"?(o=Buffer.byteLength(t),c=!1):es(t)?(o=t.size,c=!1):(t=Mt(t),o=t.length,c=Mt.readOnly),this._firstFragment?(this._firstFragment=!1,i&&n&&n.params[n._isServer?"server_no_context_takeover":"client_no_context_takeover"]&&(i=o>=n._threshold),this._compress=i):(i=!1,a=0),e.fin&&(this._firstFragment=!0);let l={[tt]:o,fin:e.fin,generateMask:this._generateMask,mask:e.mask,maskBuffer:this._maskBuffer,opcode:a,readOnly:c,rsv1:i};es(t)?this._state!==lt?this.enqueue([this.getBlobData,t,this._compress,l,s]):this.getBlobData(t,this._compress,l,s):this._state!==lt?this.enqueue([this.dispatch,t,this._compress,l,s]):this.dispatch(t,this._compress,l,s)}getBlobData(t,e,s,n){this._bufferedBytes+=s[tt],this._state=Kc,t.arrayBuffer().then(a=>{if(this._socket.destroyed){let o=new Error("The socket was closed while the blob was being read");process.nextTick(Oa,this,o,n);return}this._bufferedBytes-=s[tt];let i=Mt(a);e?this.dispatch(i,e,s,n):(this._state=lt,this.sendFrame(r.frame(i,s),n),this.dequeue())}).catch(a=>{process.nextTick(Jc,this,a,n)})}dispatch(t,e,s,n){if(!e){this.sendFrame(r.frame(t,s),n);return}let a=this._extensions[Vi.extensionName];this._bufferedBytes+=s[tt],this._state=Yc,a.compress(t,s.fin,(i,o)=>{if(this._socket.destroyed){let c=new Error("The socket was closed while data was being compressed");Oa(this,c,n);return}this._bufferedBytes-=s[tt],this._state=lt,s.readOnly=!1,this.sendFrame(r.frame(o,s),n),this.dequeue()})}dequeue(){for(;this._state===lt&&this._queue.length;){let t=this._queue.shift();this._bufferedBytes-=t[3][tt],Reflect.apply(t[0],this,t.slice(1))}}enqueue(t){this._bufferedBytes+=t[3][tt],this._queue.push(t)}sendFrame(t,e){t.length===2?(this._socket.cork(),this._socket.write(t[0]),this._socket.write(t[1],e),this._socket.uncork()):this._socket.write(t[0],e)}};Ki.exports=Fa;function Oa(r,t,e){typeof e=="function"&&e(t);for(let s=0;s{"use strict";var{kForOnEventAttribute:Es,kListener:Ba}=gt(),Ji=Symbol("kCode"),Xi=Symbol("kData"),Qi=Symbol("kError"),Zi=Symbol("kMessage"),eo=Symbol("kReason"),ss=Symbol("kTarget"),to=Symbol("kType"),so=Symbol("kWasClean"),vt=class{constructor(t){this[ss]=null,this[to]=t}get target(){return this[ss]}get type(){return this[to]}};Object.defineProperty(vt.prototype,"target",{enumerable:!0});Object.defineProperty(vt.prototype,"type",{enumerable:!0});var Ft=class extends vt{constructor(t,e={}){super(t),this[Ji]=e.code===void 0?0:e.code,this[eo]=e.reason===void 0?"":e.reason,this[so]=e.wasClean===void 0?!1:e.wasClean}get code(){return this[Ji]}get reason(){return this[eo]}get wasClean(){return this[so]}};Object.defineProperty(Ft.prototype,"code",{enumerable:!0});Object.defineProperty(Ft.prototype,"reason",{enumerable:!0});Object.defineProperty(Ft.prototype,"wasClean",{enumerable:!0});var ns=class extends vt{constructor(t,e={}){super(t),this[Qi]=e.error===void 0?null:e.error,this[Zi]=e.message===void 0?"":e.message}get error(){return this[Qi]}get message(){return this[Zi]}};Object.defineProperty(ns.prototype,"error",{enumerable:!0});Object.defineProperty(ns.prototype,"message",{enumerable:!0});var Ps=class extends vt{constructor(t,e={}){super(t),this[Xi]=e.data===void 0?null:e.data}get data(){return this[Xi]}};Object.defineProperty(Ps.prototype,"data",{enumerable:!0});var Xc={addEventListener(r,t,e={}){for(let n of this.listeners(r))if(!e[Es]&&n[Ba]===t&&!n[Es])return;let s;if(r==="message")s=function(a,i){let o=new Ps("message",{data:i?a:a.toString()});o[ss]=this,Pn(t,this,o)};else if(r==="close")s=function(a,i){let o=new Ft("close",{code:a,reason:i.toString(),wasClean:this._closeFrameReceived&&this._closeFrameSent});o[ss]=this,Pn(t,this,o)};else if(r==="error")s=function(a){let i=new ns("error",{error:a,message:a.message});i[ss]=this,Pn(t,this,i)};else if(r==="open")s=function(){let a=new vt("open");a[ss]=this,Pn(t,this,a)};else return;s[Es]=!!e[Es],s[Ba]=t,e.once?this.once(r,s):this.on(r,s)},removeEventListener(r,t){for(let e of this.listeners(r))if(e[Ba]===t&&!e[Es]){this.removeListener(r,e);break}}};no.exports={CloseEvent:Ft,ErrorEvent:ns,Event:vt,EventTarget:Xc,MessageEvent:Ps};function Pn(r,t,e){typeof r=="object"&&r.handleEvent?r.handleEvent.call(r,e):r.call(t,e)}});var Rn=Ve((Ip,ro)=>{"use strict";var{tokenChars:Rs}=Zt();function pt(r,t,e){r[t]===void 0?r[t]=[e]:r[t].push(e)}function Qc(r){let t=Object.create(null),e=Object.create(null),s=!1,n=!1,a=!1,i,o,c=-1,l=-1,h=-1,d=0;for(;d{let e=r[t];return Array.isArray(e)||(e=[e]),e.map(s=>[t].concat(Object.keys(s).map(n=>{let a=s[n];return Array.isArray(a)||(a=[a]),a.map(i=>i===!0?n:`${n}=${i}`).join("; ")})).join("; ")).join(", ")}).join(", ")}ro.exports={format:Zc,parse:Qc}});var Ln=Ve((Fp,vo)=>{"use strict";var ed=require("events"),td=require("https"),sd=require("http"),lo=require("net"),nd=require("tls"),{randomBytes:ad,createHash:rd}=require("crypto"),{Duplex:Mp,Readable:Lp}=require("stream"),{URL:Ua}=require("url"),Ct=Qt(),id=La(),od=Na(),{isBlob:ld}=Zt(),{BINARY_TYPES:io,CLOSE_TIMEOUT:cd,EMPTY_BUFFER:Dn,GUID:dd,kForOnEventAttribute:$a,kListener:hd,kStatusCode:ud,kWebSocket:De,NOOP:co}=gt(),{EventTarget:{addEventListener:pd,removeEventListener:md}}=ao(),{format:fd,parse:gd}=Rn(),{toBuffer:yd}=_s(),ho=Symbol("kAborted"),ja=[8,13],wt=["CONNECTING","OPEN","CLOSING","CLOSED"],vd=/^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/,be=class r extends ed{constructor(t,e,s){super(),this._binaryType=io[0],this._closeCode=1006,this._closeFrameReceived=!1,this._closeFrameSent=!1,this._closeMessage=Dn,this._closeTimer=null,this._errorEmitted=!1,this._extensions={},this._paused=!1,this._protocol="",this._readyState=r.CONNECTING,this._receiver=null,this._sender=null,this._socket=null,t!==null?(this._bufferedAmount=0,this._isServer=!1,this._redirects=0,e===void 0?e=[]:Array.isArray(e)||(typeof e=="object"&&e!==null?(s=e,e=[]):e=[e]),uo(this,t,e,s)):(this._autoPong=s.autoPong,this._closeTimeout=s.closeTimeout,this._isServer=!0)}get binaryType(){return this._binaryType}set binaryType(t){io.includes(t)&&(this._binaryType=t,this._receiver&&(this._receiver._binaryType=t))}get bufferedAmount(){return this._socket?this._socket._writableState.length+this._sender._bufferedBytes:this._bufferedAmount}get extensions(){return Object.keys(this._extensions).join()}get isPaused(){return this._paused}get onclose(){return null}get onerror(){return null}get onopen(){return null}get onmessage(){return null}get protocol(){return this._protocol}get readyState(){return this._readyState}get url(){return this._url}setSocket(t,e,s){let n=new id({allowSynchronousEvents:s.allowSynchronousEvents,binaryType:this.binaryType,extensions:this._extensions,isServer:this._isServer,maxBufferedChunks:s.maxBufferedChunks,maxFragments:s.maxFragments,maxPayload:s.maxPayload,skipUTF8Validation:s.skipUTF8Validation}),a=new od(t,this._extensions,s.generateMask);this._receiver=n,this._sender=a,this._socket=t,n[De]=this,a[De]=this,t[De]=this,n.on("conclude",kd),n.on("drain",xd),n.on("error",Sd),n.on("message",Cd),n.on("ping",Td),n.on("pong",_d),a.onerror=Ad,t.setTimeout&&t.setTimeout(0),t.setNoDelay&&t.setNoDelay(),e.length>0&&t.unshift(e),t.on("close",fo),t.on("data",Mn),t.on("end",go),t.on("error",yo),this._readyState=r.OPEN,this.emit("open")}emitClose(){if(!this._socket){this._readyState=r.CLOSED,this.emit("close",this._closeCode,this._closeMessage);return}this._extensions[Ct.extensionName]&&this._extensions[Ct.extensionName].cleanup(),this._receiver.removeAllListeners(),this._readyState=r.CLOSED,this.emit("close",this._closeCode,this._closeMessage)}close(t,e){if(this.readyState!==r.CLOSED){if(this.readyState===r.CONNECTING){Qe(this,this._req,"WebSocket was closed before the connection was established");return}if(this.readyState===r.CLOSING){this._closeFrameSent&&(this._closeFrameReceived||this._receiver._writableState.errorEmitted)&&this._socket.end();return}this._readyState=r.CLOSING,this._sender.close(t,e,!this._isServer,s=>{s||(this._closeFrameSent=!0,(this._closeFrameReceived||this._receiver._writableState.errorEmitted)&&this._socket.end())}),mo(this)}}pause(){this.readyState===r.CONNECTING||this.readyState===r.CLOSED||(this._paused=!0,this._socket.pause())}ping(t,e,s){if(this.readyState===r.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof t=="function"?(s=t,t=e=void 0):typeof e=="function"&&(s=e,e=void 0),typeof t=="number"&&(t=t.toString()),this.readyState!==r.OPEN){Wa(this,t,s);return}e===void 0&&(e=!this._isServer),this._sender.ping(t||Dn,e,s)}pong(t,e,s){if(this.readyState===r.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof t=="function"?(s=t,t=e=void 0):typeof e=="function"&&(s=e,e=void 0),typeof t=="number"&&(t=t.toString()),this.readyState!==r.OPEN){Wa(this,t,s);return}e===void 0&&(e=!this._isServer),this._sender.pong(t||Dn,e,s)}resume(){this.readyState===r.CONNECTING||this.readyState===r.CLOSED||(this._paused=!1,this._receiver._writableState.needDrain||this._socket.resume())}send(t,e,s){if(this.readyState===r.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof e=="function"&&(s=e,e={}),typeof t=="number"&&(t=t.toString()),this.readyState!==r.OPEN){Wa(this,t,s);return}let n={binary:typeof t!="string",mask:!this._isServer,compress:!0,fin:!0,...e};this._extensions[Ct.extensionName]||(n.compress=!1),this._sender.send(t||Dn,n,s)}terminate(){if(this.readyState!==r.CLOSED){if(this.readyState===r.CONNECTING){Qe(this,this._req,"WebSocket was closed before the connection was established");return}this._socket&&(this._readyState=r.CLOSING,this._socket.destroy())}}};Object.defineProperty(be,"CONNECTING",{enumerable:!0,value:wt.indexOf("CONNECTING")});Object.defineProperty(be.prototype,"CONNECTING",{enumerable:!0,value:wt.indexOf("CONNECTING")});Object.defineProperty(be,"OPEN",{enumerable:!0,value:wt.indexOf("OPEN")});Object.defineProperty(be.prototype,"OPEN",{enumerable:!0,value:wt.indexOf("OPEN")});Object.defineProperty(be,"CLOSING",{enumerable:!0,value:wt.indexOf("CLOSING")});Object.defineProperty(be.prototype,"CLOSING",{enumerable:!0,value:wt.indexOf("CLOSING")});Object.defineProperty(be,"CLOSED",{enumerable:!0,value:wt.indexOf("CLOSED")});Object.defineProperty(be.prototype,"CLOSED",{enumerable:!0,value:wt.indexOf("CLOSED")});["binaryType","bufferedAmount","extensions","isPaused","protocol","readyState","url"].forEach(r=>{Object.defineProperty(be.prototype,r,{enumerable:!0})});["open","error","close","message"].forEach(r=>{Object.defineProperty(be.prototype,`on${r}`,{enumerable:!0,get(){for(let t of this.listeners(r))if(t[$a])return t[hd];return null},set(t){for(let e of this.listeners(r))if(e[$a]){this.removeListener(r,e);break}typeof t=="function"&&this.addEventListener(r,t,{[$a]:!0})}})});be.prototype.addEventListener=pd;be.prototype.removeEventListener=md;vo.exports=be;function uo(r,t,e,s){let n={allowSynchronousEvents:!0,autoPong:!0,closeTimeout:cd,protocolVersion:ja[1],maxBufferedChunks:1048576,maxFragments:131072,maxPayload:104857600,skipUTF8Validation:!1,perMessageDeflate:!0,followRedirects:!1,maxRedirects:10,...s,socketPath:void 0,hostname:void 0,protocol:void 0,timeout:void 0,method:"GET",host:void 0,path:void 0,port:void 0};if(r._autoPong=n.autoPong,r._closeTimeout=n.closeTimeout,!ja.includes(n.protocolVersion))throw new RangeError(`Unsupported protocol version: ${n.protocolVersion} (supported versions: ${ja.join(", ")})`);let a;if(t instanceof Ua)a=t;else try{a=new Ua(t)}catch{throw new SyntaxError(`Invalid URL: ${t}`)}a.protocol==="http:"?a.protocol="ws:":a.protocol==="https:"&&(a.protocol="wss:"),r._url=a.href;let i=a.protocol==="wss:",o=a.protocol==="ws+unix:",c;if(a.protocol!=="ws:"&&!i&&!o?c=`The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`:o&&!a.pathname?c="The URL's pathname is empty":a.hash&&(c="The URL contains a fragment identifier"),c){let f=new SyntaxError(c);if(r._redirects===0)throw f;In(r,f);return}let l=i?443:80,h=ad(16).toString("base64"),d=i?td.request:sd.request,u=new Set,p;if(n.createConnection=n.createConnection||(i?bd:wd),n.defaultPort=n.defaultPort||l,n.port=a.port||l,n.host=a.hostname.startsWith("[")?a.hostname.slice(1,-1):a.hostname,n.headers={...n.headers,"Sec-WebSocket-Version":n.protocolVersion,"Sec-WebSocket-Key":h,Connection:"Upgrade",Upgrade:"websocket"},n.path=a.pathname+a.search,n.timeout=n.handshakeTimeout,n.perMessageDeflate&&(p=new Ct({...n.perMessageDeflate,isServer:!1,maxPayload:n.maxPayload}),n.headers["Sec-WebSocket-Extensions"]=fd({[Ct.extensionName]:p.offer()})),e.length){for(let f of e){if(typeof f!="string"||!vd.test(f)||u.has(f))throw new SyntaxError("An invalid or duplicated subprotocol was specified");u.add(f)}n.headers["Sec-WebSocket-Protocol"]=e.join(",")}if(n.origin&&(n.protocolVersion<13?n.headers["Sec-WebSocket-Origin"]=n.origin:n.headers.Origin=n.origin),(a.username||a.password)&&(n.auth=`${a.username}:${a.password}`),o){let f=n.path.split(":");n.socketPath=f[0],n.path=f[1]}let m;if(n.followRedirects){if(r._redirects===0){r._originalIpc=o,r._originalSecure=i,r._originalHostOrSocketPath=o?n.socketPath:a.host;let f=s&&s.headers;if(s={...s,headers:{}},f)for(let[g,y]of Object.entries(f))s.headers[g.toLowerCase()]=y}else if(r.listenerCount("redirect")===0){let f=o?r._originalIpc?n.socketPath===r._originalHostOrSocketPath:!1:r._originalIpc?!1:a.host===r._originalHostOrSocketPath;(!f||r._originalSecure&&!i)&&(delete n.headers.authorization,delete n.headers.cookie,f||delete n.headers.host,n.auth=void 0)}n.auth&&!s.headers.authorization&&(s.headers.authorization="Basic "+Buffer.from(n.auth).toString("base64")),m=r._req=d(n),r._redirects&&r.emit("redirect",r.url,m)}else m=r._req=d(n);n.timeout&&m.on("timeout",()=>{Qe(r,m,"Opening handshake has timed out")}),m.on("error",f=>{m===null||m[ho]||(m=r._req=null,In(r,f))}),m.on("response",f=>{let g=f.headers.location,y=f.statusCode;if(g&&n.followRedirects&&y>=300&&y<400){if(++r._redirects>n.maxRedirects){Qe(r,m,"Maximum redirects exceeded");return}m.abort();let v;try{v=new Ua(g,t)}catch{let w=new SyntaxError(`Invalid URL: ${g}`);In(r,w);return}uo(r,v,e,s)}else r.emit("unexpected-response",m,f)||Qe(r,m,`Unexpected server response: ${f.statusCode}`)}),m.on("upgrade",(f,g,y)=>{if(r.emit("upgrade",f),r.readyState!==be.CONNECTING)return;m=r._req=null;let v=f.headers.upgrade;if(v===void 0||v.toLowerCase()!=="websocket"){Qe(r,g,"Invalid Upgrade header");return}let k=rd("sha1").update(h+dd).digest("base64");if(f.headers["sec-websocket-accept"]!==k){Qe(r,g,"Invalid Sec-WebSocket-Accept header");return}let w=f.headers["sec-websocket-protocol"],S;if(w!==void 0?u.size?u.has(w)||(S="Server sent an invalid subprotocol"):S="Server sent a subprotocol but none was requested":u.size&&(S="Server sent no subprotocol"),S){Qe(r,g,S);return}w&&(r._protocol=w);let T=f.headers["sec-websocket-extensions"];if(T!==void 0){if(!p){Qe(r,g,"Server sent a Sec-WebSocket-Extensions header but no extension was requested");return}let _;try{_=gd(T)}catch{Qe(r,g,"Invalid Sec-WebSocket-Extensions header");return}let D=Object.keys(_);if(D.length!==1||D[0]!==Ct.extensionName){Qe(r,g,"Server indicated an extension that was not requested");return}try{p.accept(_[Ct.extensionName])}catch{Qe(r,g,"Invalid Sec-WebSocket-Extensions header");return}r._extensions[Ct.extensionName]=p}r.setSocket(g,y,{allowSynchronousEvents:n.allowSynchronousEvents,generateMask:n.generateMask,maxBufferedChunks:n.maxBufferedChunks,maxFragments:n.maxFragments,maxPayload:n.maxPayload,skipUTF8Validation:n.skipUTF8Validation})}),n.finishRequest?n.finishRequest(m,r):m.end()}function In(r,t){r._readyState=be.CLOSING,r._errorEmitted=!0,r.emit("error",t),r.emitClose()}function wd(r){return r.path=r.socketPath,lo.connect(r)}function bd(r){return r.path=void 0,!r.servername&&r.servername!==""&&(r.servername=lo.isIP(r.host)?"":r.host),nd.connect(r)}function Qe(r,t,e){r._readyState=be.CLOSING;let s=new Error(e);Error.captureStackTrace(s,Qe),t.setHeader?(t[ho]=!0,t.abort(),t.socket&&!t.socket.destroyed&&t.socket.destroy(),process.nextTick(In,r,s)):(t.destroy(s),t.once("error",r.emit.bind(r,"error")),t.once("close",r.emitClose.bind(r)))}function Wa(r,t,e){if(t){let s=ld(t)?t.size:yd(t).length;r._socket?r._sender._bufferedBytes+=s:r._bufferedAmount+=s}if(e){let s=new Error(`WebSocket is not open: readyState ${r.readyState} (${wt[r.readyState]})`);process.nextTick(e,s)}}function kd(r,t){let e=this[De];e._closeFrameReceived=!0,e._closeMessage=t,e._closeCode=r,e._socket[De]!==void 0&&(e._socket.removeListener("data",Mn),process.nextTick(po,e._socket),r===1005?e.close():e.close(r,t))}function xd(){let r=this[De];r.isPaused||r._socket.resume()}function Sd(r){let t=this[De];t._socket[De]!==void 0&&(t._socket.removeListener("data",Mn),process.nextTick(po,t._socket),t.close(r[ud])),t._errorEmitted||(t._errorEmitted=!0,t.emit("error",r))}function oo(){this[De].emitClose()}function Cd(r,t){this[De].emit("message",r,t)}function Td(r){let t=this[De];t._autoPong&&t.pong(r,!this._isServer,co),t.emit("ping",r)}function _d(r){this[De].emit("pong",r)}function po(r){r.resume()}function Ad(r){let t=this[De];t.readyState!==be.CLOSED&&(t.readyState===be.OPEN&&(t._readyState=be.CLOSING,mo(t)),this._socket.end(),t._errorEmitted||(t._errorEmitted=!0,t.emit("error",r)))}function mo(r){r._closeTimer=setTimeout(r._socket.destroy.bind(r._socket),r._closeTimeout)}function fo(){let r=this[De];if(this.removeListener("close",fo),this.removeListener("data",Mn),this.removeListener("end",go),r._readyState=be.CLOSING,!this._readableState.endEmitted&&!r._closeFrameReceived&&!r._receiver._writableState.errorEmitted&&this._readableState.length!==0){let t=this.read(this._readableState.length);r._receiver.write(t)}r._receiver.end(),this[De]=void 0,clearTimeout(r._closeTimer),r._receiver._writableState.finished||r._receiver._writableState.errorEmitted?r.emitClose():(r._receiver.on("error",oo),r._receiver.on("finish",oo))}function Mn(r){this[De]._receiver.write(r)||this.pause()}function go(){let r=this[De];r._readyState=be.CLOSING,r._receiver.end(),this.end()}function yo(){let r=this[De];this.removeListener("error",yo),this.on("error",co),r&&(r._readyState=be.CLOSING,this.destroy())}});var xo=Ve((Np,ko)=>{"use strict";var Op=Ln(),{Duplex:Ed}=require("stream");function wo(r){r.emit("close")}function Pd(){!this.destroyed&&this._writableState.finished&&this.destroy()}function bo(r){this.removeListener("error",bo),this.destroy(),this.listenerCount("error")===0&&this.emit("error",r)}function Rd(r,t){let e=!0,s=new Ed({...t,autoDestroy:!1,emitClose:!1,objectMode:!1,writableObjectMode:!1});return r.on("message",function(a,i){let o=!i&&s._readableState.objectMode?a.toString():a;s.push(o)||r.pause()}),r.once("error",function(a){s.destroyed||(e=!1,s.destroy(a))}),r.once("close",function(){s.destroyed||s.push(null)}),s._destroy=function(n,a){if(r.readyState===r.CLOSED){a(n),process.nextTick(wo,s);return}let i=!1;r.once("error",function(c){i=!0,a(c)}),r.once("close",function(){i||a(n),process.nextTick(wo,s)}),e&&r.terminate()},s._final=function(n){if(r.readyState===r.CONNECTING){r.once("open",function(){s._final(n)});return}r._socket!==null&&(r._socket._writableState.finished?(n(),s._readableState.endEmitted&&s.destroy()):(r._socket.once("finish",function(){n()}),r.close()))},s._read=function(){r.isPaused&&r.resume()},s._write=function(n,a,i){if(r.readyState===r.CONNECTING){r.once("open",function(){s._write(n,a,i)});return}r.send(n,i)},s.on("end",Pd),s.on("error",bo),s}ko.exports=Rd});var Ha=Ve((Bp,So)=>{"use strict";var{tokenChars:Dd}=Zt();function Id(r){let t=new Set,e=-1,s=-1,n=0;for(n;n{"use strict";var Md=require("events"),Fn=require("http"),{Duplex:Up}=require("stream"),{createHash:Ld}=require("crypto"),Co=Rn(),Ot=Qt(),Fd=Ha(),Od=Ln(),{CLOSE_TIMEOUT:Nd,GUID:Bd,kWebSocket:Ud}=gt(),$d=/^[+/0-9A-Za-z]{22}==$/,To=0,_o=1,Eo=2,qa=class extends Md{constructor(t,e){if(super(),t={allowSynchronousEvents:!0,autoPong:!0,maxBufferedChunks:1024*1024,maxFragments:128*1024,maxPayload:100*1024*1024,skipUTF8Validation:!1,perMessageDeflate:!1,handleProtocols:null,clientTracking:!0,closeTimeout:Nd,verifyClient:null,noServer:!1,backlog:null,server:null,host:null,path:null,port:null,WebSocket:Od,...t},t.port==null&&!t.server&&!t.noServer||t.port!=null&&(t.server||t.noServer)||t.server&&t.noServer)throw new TypeError('One and only one of the "port", "server", or "noServer" options must be specified');if(t.port!=null?(this._server=Fn.createServer((s,n)=>{let a=Fn.STATUS_CODES[426];n.writeHead(426,{"Content-Length":a.length,"Content-Type":"text/plain"}),n.end(a)}),this._server.listen(t.port,t.host,t.backlog,e)):t.server&&(this._server=t.server),this._server){let s=this.emit.bind(this,"connection");this._removeListeners=jd(this._server,{listening:this.emit.bind(this,"listening"),error:this.emit.bind(this,"error"),upgrade:(n,a,i)=>{this.handleUpgrade(n,a,i,s)}})}t.perMessageDeflate===!0&&(t.perMessageDeflate={}),t.clientTracking&&(this.clients=new Set,this._shouldEmitClose=!1),this.options=t,this._state=To}address(){if(this.options.noServer)throw new Error('The server is operating in "noServer" mode');return this._server?this._server.address():null}close(t){if(this._state===Eo){t&&this.once("close",()=>{t(new Error("The server is not running"))}),process.nextTick(Ds,this);return}if(t&&this.once("close",t),this._state!==_o)if(this._state=_o,this.options.noServer||this.options.server)this._server&&(this._removeListeners(),this._removeListeners=this._server=null),this.clients?this.clients.size?this._shouldEmitClose=!0:process.nextTick(Ds,this):process.nextTick(Ds,this);else{let e=this._server;this._removeListeners(),this._removeListeners=this._server=null,e.close(()=>{Ds(this)})}}shouldHandle(t){if(this.options.path){let e=t.url.indexOf("?");if((e!==-1?t.url.slice(0,e):t.url)!==this.options.path)return!1}return!0}handleUpgrade(t,e,s,n){e.on("error",Ao);let a=t.headers["sec-websocket-key"],i=t.headers.upgrade,o=+t.headers["sec-websocket-version"];if(t.method!=="GET"){Nt(this,t,e,405,"Invalid HTTP method");return}if(i===void 0||i.toLowerCase()!=="websocket"){Nt(this,t,e,400,"Invalid Upgrade header");return}if(a===void 0||!$d.test(a)){Nt(this,t,e,400,"Missing or invalid Sec-WebSocket-Key header");return}if(o!==13&&o!==8){Nt(this,t,e,400,"Missing or invalid Sec-WebSocket-Version header",{"Sec-WebSocket-Version":"13, 8"});return}if(!this.shouldHandle(t)){Is(e,400);return}let c=t.headers["sec-websocket-protocol"],l=new Set;if(c!==void 0)try{l=Fd.parse(c)}catch{Nt(this,t,e,400,"Invalid Sec-WebSocket-Protocol header");return}let h=t.headers["sec-websocket-extensions"],d={};if(this.options.perMessageDeflate&&h!==void 0){let u=new Ot({...this.options.perMessageDeflate,isServer:!0,maxPayload:this.options.maxPayload});try{let p=Co.parse(h);p[Ot.extensionName]&&(u.accept(p[Ot.extensionName]),d[Ot.extensionName]=u)}catch{Nt(this,t,e,400,"Invalid or unacceptable Sec-WebSocket-Extensions header");return}}if(this.options.verifyClient){let u={origin:t.headers[`${o===8?"sec-websocket-origin":"origin"}`],secure:!!(t.socket.authorized||t.socket.encrypted),req:t};if(this.options.verifyClient.length===2){this.options.verifyClient(u,(p,m,f,g)=>{if(!p)return Is(e,m||401,f,g);this.completeUpgrade(d,a,l,t,e,s,n)});return}if(!this.options.verifyClient(u))return Is(e,401)}this.completeUpgrade(d,a,l,t,e,s,n)}completeUpgrade(t,e,s,n,a,i,o){if(!a.readable||!a.writable)return a.destroy();if(a[Ud])throw new Error("server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration");if(this._state>To)return Is(a,503);let l=["HTTP/1.1 101 Switching Protocols","Upgrade: websocket","Connection: Upgrade",`Sec-WebSocket-Accept: ${Ld("sha1").update(e+Bd).digest("base64")}`],h=new this.options.WebSocket(null,void 0,this.options);if(s.size){let d=this.options.handleProtocols?this.options.handleProtocols(s,n):s.values().next().value;d&&(l.push(`Sec-WebSocket-Protocol: ${d}`),h._protocol=d)}if(t[Ot.extensionName]){let d=t[Ot.extensionName].params,u=Co.format({[Ot.extensionName]:[d]});l.push(`Sec-WebSocket-Extensions: ${u}`),h._extensions=t}this.emit("headers",l,n),a.write(l.concat(`\r +"use strict";var Oc=Object.create;var wn=Object.defineProperty;var Nc=Object.getOwnPropertyDescriptor;var Bc=Object.getOwnPropertyNames;var Uc=Object.getPrototypeOf,$c=Object.prototype.hasOwnProperty;var tt=(a,e)=>()=>(e||a((e={exports:{}}).exports,e),e.exports),jc=(a,e)=>{for(var t in e)wn(a,t,{get:e[t],enumerable:!0})},Jr=(a,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of Bc(e))!$c.call(a,n)&&n!==t&&wn(a,n,{get:()=>e[n],enumerable:!(s=Nc(e,n))||s.enumerable});return a};var st=(a,e,t)=>(t=a!=null?Oc(Uc(a)):{},Jr(e||!a||!a.__esModule?wn(t,"default",{value:a,enumerable:!0}):t,a)),Hc=a=>Jr(wn({},"__esModule",{value:!0}),a);var Rt=tt((ig,Io)=>{"use strict";var Do=["nodebuffer","arraybuffer","fragments"],Ro=typeof Blob<"u";Ro&&Do.push("blob");Io.exports={BINARY_TYPES:Do,CLOSE_TIMEOUT:3e4,EMPTY_BUFFER:Buffer.alloc(0),GUID:"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",hasBlob:Ro,kForOnEventAttribute:Symbol("kIsForOnEventAttribute"),kListener:Symbol("kListener"),kStatusCode:Symbol("status-code"),kWebSocket:Symbol("websocket"),NOOP:()=>{}}});var Zs=tt((og,ia)=>{"use strict";var{EMPTY_BUFFER:tu}=Rt(),fr=Buffer[Symbol.species];function su(a,e){if(a.length===0)return tu;if(a.length===1)return a[0];let t=Buffer.allocUnsafe(e),s=0;for(let n=0;n{"use strict";var Lo=Symbol("kDone"),yr=Symbol("kRun"),vr=class{constructor(e){this[Lo]=()=>{this.pending--,this[yr]()},this.concurrency=e||1/0,this.jobs=[],this.pending=0}add(e){this.jobs.push(e),this[yr]()}[yr](){if(this.pending!==this.concurrency&&this.jobs.length){let e=this.jobs.shift();this.pending++,e(this[Lo])}}};Oo.exports=vr});var ks=tt((cg,jo)=>{"use strict";var en=require("zlib"),Bo=Zs(),au=No(),{kStatusCode:Uo}=Rt(),ru=Buffer[Symbol.species],iu=Buffer.from([0,0,255,255]),la=Symbol("permessage-deflate"),It=Symbol("total-length"),ws=Symbol("callback"),$t=Symbol("buffers"),bs=Symbol("error"),oa,wr=class{constructor(e){if(this._options=e||{},this._threshold=this._options.threshold!==void 0?this._options.threshold:1024,this._maxPayload=this._options.maxPayload|0,this._isServer=!!this._options.isServer,this._deflate=null,this._inflate=null,this.params=null,!oa){let t=this._options.concurrencyLimit!==void 0?this._options.concurrencyLimit:10;oa=new au(t)}}static get extensionName(){return"permessage-deflate"}offer(){let e={};return this._options.serverNoContextTakeover&&(e.server_no_context_takeover=!0),this._options.clientNoContextTakeover&&(e.client_no_context_takeover=!0),this._options.serverMaxWindowBits&&(e.server_max_window_bits=this._options.serverMaxWindowBits),this._options.clientMaxWindowBits?e.client_max_window_bits=this._options.clientMaxWindowBits:this._options.clientMaxWindowBits==null&&(e.client_max_window_bits=!0),e}accept(e){return e=this.normalizeParams(e),this.params=this._isServer?this.acceptAsServer(e):this.acceptAsClient(e),this.params}cleanup(){if(this._inflate&&(this._inflate.close(),this._inflate=null),this._deflate){let e=this._deflate[ws];this._deflate.close(),this._deflate=null,e&&e(new Error("The deflate stream was closed while data was being processed"))}}acceptAsServer(e){let t=this._options,s=e.find(n=>!(t.serverNoContextTakeover===!1&&n.server_no_context_takeover||n.server_max_window_bits&&(t.serverMaxWindowBits===!1||typeof t.serverMaxWindowBits=="number"&&t.serverMaxWindowBits>n.server_max_window_bits)||typeof t.clientMaxWindowBits=="number"&&!n.client_max_window_bits));if(!s)throw new Error("None of the extension offers can be accepted");return t.serverNoContextTakeover&&(s.server_no_context_takeover=!0),t.clientNoContextTakeover&&(s.client_no_context_takeover=!0),typeof t.serverMaxWindowBits=="number"&&(s.server_max_window_bits=t.serverMaxWindowBits),typeof t.clientMaxWindowBits=="number"?s.client_max_window_bits=t.clientMaxWindowBits:(s.client_max_window_bits===!0||t.clientMaxWindowBits===!1)&&delete s.client_max_window_bits,s}acceptAsClient(e){let t=e[0];if(this._options.clientNoContextTakeover===!1&&t.client_no_context_takeover)throw new Error('Unexpected parameter "client_no_context_takeover"');if(!t.client_max_window_bits)typeof this._options.clientMaxWindowBits=="number"&&(t.client_max_window_bits=this._options.clientMaxWindowBits);else if(this._options.clientMaxWindowBits===!1||typeof this._options.clientMaxWindowBits=="number"&&t.client_max_window_bits>this._options.clientMaxWindowBits)throw new Error('Unexpected or invalid parameter "client_max_window_bits"');return t}normalizeParams(e){return e.forEach(t=>{Object.keys(t).forEach(s=>{let n=t[s];if(n.length>1)throw new Error(`Parameter "${s}" must have only a single value`);if(n=n[0],s==="client_max_window_bits"){if(n!==!0){let r=+n;if(!Number.isInteger(r)||r<8||r>15)throw new TypeError(`Invalid value for parameter "${s}": ${n}`);n=r}else if(!this._isServer)throw new TypeError(`Invalid value for parameter "${s}": ${n}`)}else if(s==="server_max_window_bits"){let r=+n;if(!Number.isInteger(r)||r<8||r>15)throw new TypeError(`Invalid value for parameter "${s}": ${n}`);n=r}else if(s==="client_no_context_takeover"||s==="server_no_context_takeover"){if(n!==!0)throw new TypeError(`Invalid value for parameter "${s}": ${n}`)}else throw new Error(`Unknown parameter "${s}"`);t[s]=n})}),e}decompress(e,t,s){oa.add(n=>{this._decompress(e,t,(r,i)=>{n(),s(r,i)})})}compress(e,t,s){oa.add(n=>{this._compress(e,t,(r,i)=>{n(),s(r,i)})})}_decompress(e,t,s){let n=this._isServer?"client":"server";if(!this._inflate){let r=`${n}_max_window_bits`,i=typeof this.params[r]!="number"?en.Z_DEFAULT_WINDOWBITS:this.params[r];this._inflate=en.createInflateRaw({...this._options.zlibInflateOptions,windowBits:i}),this._inflate[la]=this,this._inflate[It]=0,this._inflate[$t]=[],this._inflate.on("error",lu),this._inflate.on("data",$o)}this._inflate[ws]=s,this._inflate.write(e),t&&this._inflate.write(iu),this._inflate.flush(()=>{let r=this._inflate[bs];if(r){this._inflate.close(),this._inflate=null,s(r);return}let i=Bo.concat(this._inflate[$t],this._inflate[It]);this._inflate._readableState.endEmitted?(this._inflate.close(),this._inflate=null):(this._inflate[It]=0,this._inflate[$t]=[],t&&this.params[`${n}_no_context_takeover`]&&this._inflate.reset()),s(null,i)})}_compress(e,t,s){let n=this._isServer?"server":"client";if(!this._deflate){let r=`${n}_max_window_bits`,i=typeof this.params[r]!="number"?en.Z_DEFAULT_WINDOWBITS:this.params[r];this._deflate=en.createDeflateRaw({...this._options.zlibDeflateOptions,windowBits:i}),this._deflate[It]=0,this._deflate[$t]=[],this._deflate.on("data",ou)}this._deflate[ws]=s,this._deflate.write(e),this._deflate.flush(en.Z_SYNC_FLUSH,()=>{if(!this._deflate)return;let r=Bo.concat(this._deflate[$t],this._deflate[It]);t&&(r=new ru(r.buffer,r.byteOffset,r.length-4)),this._deflate[ws]=null,this._deflate[It]=0,this._deflate[$t]=[],t&&this.params[`${n}_no_context_takeover`]&&this._deflate.reset(),s(null,r)})}};jo.exports=wr;function ou(a){this[$t].push(a),this[It]+=a.length}function $o(a){if(this[It]+=a.length,this[la]._maxPayload<1||this[It]<=this[la]._maxPayload){this[$t].push(a);return}this[bs]=new RangeError("Max payload size exceeded"),this[bs].code="WS_ERR_UNSUPPORTED_MESSAGE_LENGTH",this[bs][Uo]=1009,this.removeListener("data",$o),this.reset()}function lu(a){if(this[la]._inflate=null,this[bs]){this[ws](this[bs]);return}a[Uo]=1007,this[ws](a)}});var xs=tt((dg,ca)=>{"use strict";var{isUtf8:Ho}=require("buffer"),{hasBlob:cu}=Rt(),du=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,0];function uu(a){return a>=1e3&&a<=1014&&a!==1004&&a!==1005&&a!==1006||a>=3e3&&a<=4999}function br(a){let e=a.length,t=0;for(;t=e||(a[t+1]&192)!==128||(a[t+2]&192)!==128||a[t]===224&&(a[t+1]&224)===128||a[t]===237&&(a[t+1]&224)===160)return!1;t+=3}else if((a[t]&248)===240){if(t+3>=e||(a[t+1]&192)!==128||(a[t+2]&192)!==128||(a[t+3]&192)!==128||a[t]===240&&(a[t+1]&240)===128||a[t]===244&&a[t+1]>143||a[t]>244)return!1;t+=4}else return!1;return!0}function hu(a){return cu&&typeof a=="object"&&typeof a.arrayBuffer=="function"&&typeof a.type=="string"&&typeof a.stream=="function"&&(a[Symbol.toStringTag]==="Blob"||a[Symbol.toStringTag]==="File")}ca.exports={isBlob:hu,isValidStatusCode:uu,isValidUTF8:br,tokenChars:du};if(Ho)ca.exports.isValidUTF8=function(a){return a.length<24?br(a):Ho(a)};else if(!process.env.WS_NO_UTF_8_VALIDATE)try{let a=require("utf-8-validate");ca.exports.isValidUTF8=function(e){return e.length<32?br(e):a(e)}}catch{}});var Tr=tt((ug,Ko)=>{"use strict";var{Writable:pu}=require("stream"),Wo=ks(),{BINARY_TYPES:mu,EMPTY_BUFFER:qo,kStatusCode:fu,kWebSocket:gu}=Rt(),{concat:kr,toArrayBuffer:yu,unmask:vu}=Zs(),{isValidStatusCode:wu,isValidUTF8:zo}=xs(),da=Buffer[Symbol.species],ht=0,Go=1,Vo=2,Yo=3,xr=4,Sr=5,ua=6,Cr=class extends pu{constructor(e={}){super(),this._allowSynchronousEvents=e.allowSynchronousEvents!==void 0?e.allowSynchronousEvents:!0,this._binaryType=e.binaryType||mu[0],this._extensions=e.extensions||{},this._isServer=!!e.isServer,this._maxBufferedChunks=e.maxBufferedChunks|0,this._maxFragments=e.maxFragments|0,this._maxPayload=e.maxPayload|0,this._skipUTF8Validation=!!e.skipUTF8Validation,this[gu]=void 0,this._bufferedBytes=0,this._buffers=[],this._compressed=!1,this._payloadLength=0,this._mask=void 0,this._fragmented=0,this._masked=!1,this._fin=!1,this._opcode=0,this._totalPayloadLength=0,this._messageLength=0,this._fragments=[],this._errored=!1,this._loop=!1,this._state=ht}_write(e,t,s){if(this._opcode===8&&this._state==ht)return s();if(this._maxBufferedChunks>0&&this._buffers.length>=this._maxBufferedChunks){s(this.createError(RangeError,"Too many buffered chunks",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS"));return}this._bufferedBytes+=e.length,this._buffers.push(e),this.startLoop(s)}consume(e){if(this._bufferedBytes-=e,e===this._buffers[0].length)return this._buffers.shift();if(e=s.length?t.set(this._buffers.shift(),n):(t.set(new Uint8Array(s.buffer,s.byteOffset,e),n),this._buffers[0]=new da(s.buffer,s.byteOffset+e,s.length-e)),e-=s.length}while(e>0);return t}startLoop(e){this._loop=!0;do switch(this._state){case ht:this.getInfo(e);break;case Go:this.getPayloadLength16(e);break;case Vo:this.getPayloadLength64(e);break;case Yo:this.getMask();break;case xr:this.getData(e);break;case Sr:case ua:this._loop=!1;return}while(this._loop);this._errored||e()}getInfo(e){if(this._bufferedBytes<2){this._loop=!1;return}let t=this.consume(2);if((t[0]&48)!==0){let n=this.createError(RangeError,"RSV2 and RSV3 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_2_3");e(n);return}let s=(t[0]&64)===64;if(s&&!this._extensions[Wo.extensionName]){let n=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");e(n);return}if(this._fin=(t[0]&128)===128,this._opcode=t[0]&15,this._payloadLength=t[1]&127,this._opcode===0){if(s){let n=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");e(n);return}if(!this._fragmented){let n=this.createError(RangeError,"invalid opcode 0",!0,1002,"WS_ERR_INVALID_OPCODE");e(n);return}this._opcode=this._fragmented}else if(this._opcode===1||this._opcode===2){if(this._fragmented){let n=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");e(n);return}this._compressed=s}else if(this._opcode>7&&this._opcode<11){if(!this._fin){let n=this.createError(RangeError,"FIN must be set",!0,1002,"WS_ERR_EXPECTED_FIN");e(n);return}if(s){let n=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");e(n);return}if(this._payloadLength>125||this._opcode===8&&this._payloadLength===1){let n=this.createError(RangeError,`invalid payload length ${this._payloadLength}`,!0,1002,"WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH");e(n);return}}else{let n=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");e(n);return}if(!this._fin&&!this._fragmented&&(this._fragmented=this._opcode),this._masked=(t[1]&128)===128,this._isServer){if(!this._masked){let n=this.createError(RangeError,"MASK must be set",!0,1002,"WS_ERR_EXPECTED_MASK");e(n);return}}else if(this._masked){let n=this.createError(RangeError,"MASK must be clear",!0,1002,"WS_ERR_UNEXPECTED_MASK");e(n);return}this._payloadLength===126?this._state=Go:this._payloadLength===127?this._state=Vo:this.haveLength(e)}getPayloadLength16(e){if(this._bufferedBytes<2){this._loop=!1;return}this._payloadLength=this.consume(2).readUInt16BE(0),this.haveLength(e)}getPayloadLength64(e){if(this._bufferedBytes<8){this._loop=!1;return}let t=this.consume(8),s=t.readUInt32BE(0);if(s>Math.pow(2,21)-1){let n=this.createError(RangeError,"Unsupported WebSocket frame: payload length > 2^53 - 1",!1,1009,"WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH");e(n);return}this._payloadLength=s*Math.pow(2,32)+t.readUInt32BE(4),this.haveLength(e)}haveLength(e){if(this._payloadLength&&this._opcode<8&&(this._totalPayloadLength+=this._payloadLength,this._totalPayloadLength>this._maxPayload&&this._maxPayload>0)){let t=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");e(t);return}this._masked?this._state=Yo:this._state=xr}getMask(){if(this._bufferedBytes<4){this._loop=!1;return}this._mask=this.consume(4),this._state=xr}getData(e){let t=qo;if(this._payloadLength){if(this._bufferedBytes7){this.controlMessage(t,e);return}if(this._compressed){this._state=Sr,this.decompress(t,e);return}if(t.length){if(this._maxFragments>0&&this._fragments.length>=this._maxFragments){let s=this.createError(RangeError,"Too many message fragments",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS");e(s);return}this._messageLength=this._totalPayloadLength,this._fragments.push(t)}this.dataMessage(e)}decompress(e,t){this._extensions[Wo.extensionName].decompress(e,this._fin,(n,r)=>{if(n)return t(n);if(r.length){if(this._messageLength+=r.length,this._messageLength>this._maxPayload&&this._maxPayload>0){let i=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");t(i);return}if(this._maxFragments>0&&this._fragments.length>=this._maxFragments){let i=this.createError(RangeError,"Too many message fragments",!1,1008,"WS_ERR_TOO_MANY_BUFFERED_PARTS");t(i);return}this._fragments.push(r)}this.dataMessage(t),this._state===ht&&this.startLoop(t)})}dataMessage(e){if(!this._fin){this._state=ht;return}let t=this._messageLength,s=this._fragments;if(this._totalPayloadLength=0,this._messageLength=0,this._fragmented=0,this._fragments=[],this._opcode===2){let n;this._binaryType==="nodebuffer"?n=kr(s,t):this._binaryType==="arraybuffer"?n=yu(kr(s,t)):this._binaryType==="blob"?n=new Blob(s):n=s,this._allowSynchronousEvents?(this.emit("message",n,!0),this._state=ht):(this._state=ua,setImmediate(()=>{this.emit("message",n,!0),this._state=ht,this.startLoop(e)}))}else{let n=kr(s,t);if(!this._skipUTF8Validation&&!zo(n)){let r=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");e(r);return}this._state===Sr||this._allowSynchronousEvents?(this.emit("message",n,!1),this._state=ht):(this._state=ua,setImmediate(()=>{this.emit("message",n,!1),this._state=ht,this.startLoop(e)}))}}controlMessage(e,t){if(this._opcode===8){if(e.length===0)this._loop=!1,this.emit("conclude",1005,qo),this.end();else{let s=e.readUInt16BE(0);if(!wu(s)){let r=this.createError(RangeError,`invalid status code ${s}`,!0,1002,"WS_ERR_INVALID_CLOSE_CODE");t(r);return}let n=new da(e.buffer,e.byteOffset+2,e.length-2);if(!this._skipUTF8Validation&&!zo(n)){let r=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");t(r);return}this._loop=!1,this.emit("conclude",s,n),this.end()}this._state=ht;return}this._allowSynchronousEvents?(this.emit(this._opcode===9?"ping":"pong",e),this._state=ht):(this._state=ua,setImmediate(()=>{this.emit(this._opcode===9?"ping":"pong",e),this._state=ht,this.startLoop(t)}))}createError(e,t,s,n,r){this._loop=!1,this._errored=!0;let i=new e(s?`Invalid WebSocket frame: ${t}`:t);return Error.captureStackTrace(i,this.createError),i.code=r,i[fu]=n,i}};Ko.exports=Cr});var Pr=tt((pg,Qo)=>{"use strict";var{Duplex:hg}=require("stream"),{randomFillSync:bu}=require("crypto"),{types:{isUint8Array:ku}}=require("util"),Jo=ks(),{EMPTY_BUFFER:xu,kWebSocket:Su,NOOP:Cu}=Rt(),{isBlob:Ss,isValidStatusCode:Tu}=xs(),{mask:Xo,toBuffer:ns}=Zs(),pt=Symbol("kByteLength"),_u=Buffer.alloc(4),ha=8*1024,as,Cs=ha,yt=0,Au=1,Pu=2,_r=class a{constructor(e,t,s){this._extensions=t||{},s&&(this._generateMask=s,this._maskBuffer=Buffer.alloc(4)),this._socket=e,this._firstFragment=!0,this._compress=!1,this._bufferedBytes=0,this._queue=[],this._state=yt,this.onerror=Cu,this[Su]=void 0}static frame(e,t){let s,n=!1,r=2,i=!1;t.mask&&(s=t.maskBuffer||_u,t.generateMask?t.generateMask(s):(Cs===ha&&(as===void 0&&(as=Buffer.alloc(ha)),bu(as,0,ha),Cs=0),s[0]=as[Cs++],s[1]=as[Cs++],s[2]=as[Cs++],s[3]=as[Cs++]),i=(s[0]|s[1]|s[2]|s[3])===0,r=6);let o;typeof e=="string"?(!t.mask||i)&&t[pt]!==void 0?o=t[pt]:(e=Buffer.from(e),o=e.length):(o=e.length,n=t.mask&&t.readOnly&&!i);let l=o;o>=65536?(r+=8,l=127):o>125&&(r+=2,l=126);let c=Buffer.allocUnsafe(n?o+r:r);return c[0]=t.fin?t.opcode|128:t.opcode,t.rsv1&&(c[0]|=64),c[1]=l,l===126?c.writeUInt16BE(o,2):l===127&&(c[2]=c[3]=0,c.writeUIntBE(o,4,6)),t.mask?(c[1]|=128,c[r-4]=s[0],c[r-3]=s[1],c[r-2]=s[2],c[r-1]=s[3],i?[c,e]:n?(Xo(e,s,c,r,o),[c]):(Xo(e,s,e,0,o),[c,e])):[c,e]}close(e,t,s,n){let r;if(e===void 0)r=xu;else{if(typeof e!="number"||!Tu(e))throw new TypeError("First argument must be a valid error code number");if(t===void 0||!t.length)r=Buffer.allocUnsafe(2),r.writeUInt16BE(e,0);else{let o=Buffer.byteLength(t);if(o>123)throw new RangeError("The message must not be greater than 123 bytes");if(r=Buffer.allocUnsafe(2+o),r.writeUInt16BE(e,0),typeof t=="string")r.write(t,2);else if(ku(t))r.set(t,2);else throw new TypeError("Second argument must be a string or a Uint8Array")}}let i={[pt]:r.length,fin:!0,generateMask:this._generateMask,mask:s,maskBuffer:this._maskBuffer,opcode:8,readOnly:!1,rsv1:!1};this._state!==yt?this.enqueue([this.dispatch,r,!1,i,n]):this.sendFrame(a.frame(r,i),n)}ping(e,t,s){let n,r;if(typeof e=="string"?(n=Buffer.byteLength(e),r=!1):Ss(e)?(n=e.size,r=!1):(e=ns(e),n=e.length,r=ns.readOnly),n>125)throw new RangeError("The data size must not be greater than 125 bytes");let i={[pt]:n,fin:!0,generateMask:this._generateMask,mask:t,maskBuffer:this._maskBuffer,opcode:9,readOnly:r,rsv1:!1};Ss(e)?this._state!==yt?this.enqueue([this.getBlobData,e,!1,i,s]):this.getBlobData(e,!1,i,s):this._state!==yt?this.enqueue([this.dispatch,e,!1,i,s]):this.sendFrame(a.frame(e,i),s)}pong(e,t,s){let n,r;if(typeof e=="string"?(n=Buffer.byteLength(e),r=!1):Ss(e)?(n=e.size,r=!1):(e=ns(e),n=e.length,r=ns.readOnly),n>125)throw new RangeError("The data size must not be greater than 125 bytes");let i={[pt]:n,fin:!0,generateMask:this._generateMask,mask:t,maskBuffer:this._maskBuffer,opcode:10,readOnly:r,rsv1:!1};Ss(e)?this._state!==yt?this.enqueue([this.getBlobData,e,!1,i,s]):this.getBlobData(e,!1,i,s):this._state!==yt?this.enqueue([this.dispatch,e,!1,i,s]):this.sendFrame(a.frame(e,i),s)}send(e,t,s){let n=this._extensions[Jo.extensionName],r=t.binary?2:1,i=t.compress,o,l;typeof e=="string"?(o=Buffer.byteLength(e),l=!1):Ss(e)?(o=e.size,l=!1):(e=ns(e),o=e.length,l=ns.readOnly),this._firstFragment?(this._firstFragment=!1,i&&n&&n.params[n._isServer?"server_no_context_takeover":"client_no_context_takeover"]&&(i=o>=n._threshold),this._compress=i):(i=!1,r=0),t.fin&&(this._firstFragment=!0);let c={[pt]:o,fin:t.fin,generateMask:this._generateMask,mask:t.mask,maskBuffer:this._maskBuffer,opcode:r,readOnly:l,rsv1:i};Ss(e)?this._state!==yt?this.enqueue([this.getBlobData,e,this._compress,c,s]):this.getBlobData(e,this._compress,c,s):this._state!==yt?this.enqueue([this.dispatch,e,this._compress,c,s]):this.dispatch(e,this._compress,c,s)}getBlobData(e,t,s,n){this._bufferedBytes+=s[pt],this._state=Pu,e.arrayBuffer().then(r=>{if(this._socket.destroyed){let o=new Error("The socket was closed while the blob was being read");process.nextTick(Ar,this,o,n);return}this._bufferedBytes-=s[pt];let i=ns(r);t?this.dispatch(i,t,s,n):(this._state=yt,this.sendFrame(a.frame(i,s),n),this.dequeue())}).catch(r=>{process.nextTick(Eu,this,r,n)})}dispatch(e,t,s,n){if(!t){this.sendFrame(a.frame(e,s),n);return}let r=this._extensions[Jo.extensionName];this._bufferedBytes+=s[pt],this._state=Au,r.compress(e,s.fin,(i,o)=>{if(this._socket.destroyed){let l=new Error("The socket was closed while data was being compressed");Ar(this,l,n);return}this._bufferedBytes-=s[pt],this._state=yt,s.readOnly=!1,this.sendFrame(a.frame(o,s),n),this.dequeue()})}dequeue(){for(;this._state===yt&&this._queue.length;){let e=this._queue.shift();this._bufferedBytes-=e[3][pt],Reflect.apply(e[0],this,e.slice(1))}}enqueue(e){this._bufferedBytes+=e[3][pt],this._queue.push(e)}sendFrame(e,t){e.length===2?(this._socket.cork(),this._socket.write(e[0]),this._socket.write(e[1],t),this._socket.uncork()):this._socket.write(e[0],t)}};Qo.exports=_r;function Ar(a,e,t){typeof t=="function"&&t(e);for(let s=0;s{"use strict";var{kForOnEventAttribute:tn,kListener:Er}=Rt(),Zo=Symbol("kCode"),el=Symbol("kData"),tl=Symbol("kError"),sl=Symbol("kMessage"),nl=Symbol("kReason"),Ts=Symbol("kTarget"),al=Symbol("kType"),rl=Symbol("kWasClean"),Mt=class{constructor(e){this[Ts]=null,this[al]=e}get target(){return this[Ts]}get type(){return this[al]}};Object.defineProperty(Mt.prototype,"target",{enumerable:!0});Object.defineProperty(Mt.prototype,"type",{enumerable:!0});var rs=class extends Mt{constructor(e,t={}){super(e),this[Zo]=t.code===void 0?0:t.code,this[nl]=t.reason===void 0?"":t.reason,this[rl]=t.wasClean===void 0?!1:t.wasClean}get code(){return this[Zo]}get reason(){return this[nl]}get wasClean(){return this[rl]}};Object.defineProperty(rs.prototype,"code",{enumerable:!0});Object.defineProperty(rs.prototype,"reason",{enumerable:!0});Object.defineProperty(rs.prototype,"wasClean",{enumerable:!0});var _s=class extends Mt{constructor(e,t={}){super(e),this[tl]=t.error===void 0?null:t.error,this[sl]=t.message===void 0?"":t.message}get error(){return this[tl]}get message(){return this[sl]}};Object.defineProperty(_s.prototype,"error",{enumerable:!0});Object.defineProperty(_s.prototype,"message",{enumerable:!0});var sn=class extends Mt{constructor(e,t={}){super(e),this[el]=t.data===void 0?null:t.data}get data(){return this[el]}};Object.defineProperty(sn.prototype,"data",{enumerable:!0});var Du={addEventListener(a,e,t={}){for(let n of this.listeners(a))if(!t[tn]&&n[Er]===e&&!n[tn])return;let s;if(a==="message")s=function(r,i){let o=new sn("message",{data:i?r:r.toString()});o[Ts]=this,pa(e,this,o)};else if(a==="close")s=function(r,i){let o=new rs("close",{code:r,reason:i.toString(),wasClean:this._closeFrameReceived&&this._closeFrameSent});o[Ts]=this,pa(e,this,o)};else if(a==="error")s=function(r){let i=new _s("error",{error:r,message:r.message});i[Ts]=this,pa(e,this,i)};else if(a==="open")s=function(){let r=new Mt("open");r[Ts]=this,pa(e,this,r)};else return;s[tn]=!!t[tn],s[Er]=e,t.once?this.once(a,s):this.on(a,s)},removeEventListener(a,e){for(let t of this.listeners(a))if(t[Er]===e&&!t[tn]){this.removeListener(a,t);break}}};il.exports={CloseEvent:rs,ErrorEvent:_s,Event:Mt,EventTarget:Du,MessageEvent:sn};function pa(a,e,t){typeof a=="object"&&a.handleEvent?a.handleEvent.call(a,t):a.call(e,t)}});var ma=tt((fg,ll)=>{"use strict";var{tokenChars:nn}=xs();function St(a,e,t){a[e]===void 0?a[e]=[t]:a[e].push(t)}function Ru(a){let e=Object.create(null),t=Object.create(null),s=!1,n=!1,r=!1,i,o,l=-1,c=-1,d=-1,u=0;for(;u{let t=a[e];return Array.isArray(t)||(t=[t]),t.map(s=>[e].concat(Object.keys(s).map(n=>{let r=s[n];return Array.isArray(r)||(r=[r]),r.map(i=>i===!0?n:`${n}=${i}`).join("; ")})).join("; ")).join(", ")}).join(", ")}ll.exports={format:Iu,parse:Ru}});var va=tt((vg,bl)=>{"use strict";var Mu=require("events"),Fu=require("https"),Lu=require("http"),ul=require("net"),Ou=require("tls"),{randomBytes:Nu,createHash:Bu}=require("crypto"),{Duplex:gg,Readable:yg}=require("stream"),{URL:Dr}=require("url"),jt=ks(),Uu=Tr(),$u=Pr(),{isBlob:ju}=xs(),{BINARY_TYPES:cl,CLOSE_TIMEOUT:Hu,EMPTY_BUFFER:fa,GUID:Wu,kForOnEventAttribute:Rr,kListener:qu,kStatusCode:zu,kWebSocket:Me,NOOP:hl}=Rt(),{EventTarget:{addEventListener:Gu,removeEventListener:Vu}}=ol(),{format:Yu,parse:Ku}=ma(),{toBuffer:Ju}=Zs(),pl=Symbol("kAborted"),Ir=[8,13],Ft=["CONNECTING","OPEN","CLOSING","CLOSED"],Xu=/^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/,he=class a extends Mu{constructor(e,t,s){super(),this._binaryType=cl[0],this._closeCode=1006,this._closeFrameReceived=!1,this._closeFrameSent=!1,this._closeMessage=fa,this._closeTimer=null,this._errorEmitted=!1,this._extensions={},this._paused=!1,this._protocol="",this._readyState=a.CONNECTING,this._receiver=null,this._sender=null,this._socket=null,e!==null?(this._bufferedAmount=0,this._isServer=!1,this._redirects=0,t===void 0?t=[]:Array.isArray(t)||(typeof t=="object"&&t!==null?(s=t,t=[]):t=[t]),ml(this,e,t,s)):(this._autoPong=s.autoPong,this._closeTimeout=s.closeTimeout,this._isServer=!0)}get binaryType(){return this._binaryType}set binaryType(e){cl.includes(e)&&(this._binaryType=e,this._receiver&&(this._receiver._binaryType=e))}get bufferedAmount(){return this._socket?this._socket._writableState.length+this._sender._bufferedBytes:this._bufferedAmount}get extensions(){return Object.keys(this._extensions).join()}get isPaused(){return this._paused}get onclose(){return null}get onerror(){return null}get onopen(){return null}get onmessage(){return null}get protocol(){return this._protocol}get readyState(){return this._readyState}get url(){return this._url}setSocket(e,t,s){let n=new Uu({allowSynchronousEvents:s.allowSynchronousEvents,binaryType:this.binaryType,extensions:this._extensions,isServer:this._isServer,maxBufferedChunks:s.maxBufferedChunks,maxFragments:s.maxFragments,maxPayload:s.maxPayload,skipUTF8Validation:s.skipUTF8Validation}),r=new $u(e,this._extensions,s.generateMask);this._receiver=n,this._sender=r,this._socket=e,n[Me]=this,r[Me]=this,e[Me]=this,n.on("conclude",eh),n.on("drain",th),n.on("error",sh),n.on("message",nh),n.on("ping",ah),n.on("pong",rh),r.onerror=ih,e.setTimeout&&e.setTimeout(0),e.setNoDelay&&e.setNoDelay(),t.length>0&&e.unshift(t),e.on("close",yl),e.on("data",ya),e.on("end",vl),e.on("error",wl),this._readyState=a.OPEN,this.emit("open")}emitClose(){if(!this._socket){this._readyState=a.CLOSED,this.emit("close",this._closeCode,this._closeMessage);return}this._extensions[jt.extensionName]&&this._extensions[jt.extensionName].cleanup(),this._receiver.removeAllListeners(),this._readyState=a.CLOSED,this.emit("close",this._closeCode,this._closeMessage)}close(e,t){if(this.readyState!==a.CLOSED){if(this.readyState===a.CONNECTING){it(this,this._req,"WebSocket was closed before the connection was established");return}if(this.readyState===a.CLOSING){this._closeFrameSent&&(this._closeFrameReceived||this._receiver._writableState.errorEmitted)&&this._socket.end();return}this._readyState=a.CLOSING,this._sender.close(e,t,!this._isServer,s=>{s||(this._closeFrameSent=!0,(this._closeFrameReceived||this._receiver._writableState.errorEmitted)&&this._socket.end())}),gl(this)}}pause(){this.readyState===a.CONNECTING||this.readyState===a.CLOSED||(this._paused=!0,this._socket.pause())}ping(e,t,s){if(this.readyState===a.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof e=="function"?(s=e,e=t=void 0):typeof t=="function"&&(s=t,t=void 0),typeof e=="number"&&(e=e.toString()),this.readyState!==a.OPEN){Mr(this,e,s);return}t===void 0&&(t=!this._isServer),this._sender.ping(e||fa,t,s)}pong(e,t,s){if(this.readyState===a.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof e=="function"?(s=e,e=t=void 0):typeof t=="function"&&(s=t,t=void 0),typeof e=="number"&&(e=e.toString()),this.readyState!==a.OPEN){Mr(this,e,s);return}t===void 0&&(t=!this._isServer),this._sender.pong(e||fa,t,s)}resume(){this.readyState===a.CONNECTING||this.readyState===a.CLOSED||(this._paused=!1,this._receiver._writableState.needDrain||this._socket.resume())}send(e,t,s){if(this.readyState===a.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof t=="function"&&(s=t,t={}),typeof e=="number"&&(e=e.toString()),this.readyState!==a.OPEN){Mr(this,e,s);return}let n={binary:typeof e!="string",mask:!this._isServer,compress:!0,fin:!0,...t};this._extensions[jt.extensionName]||(n.compress=!1),this._sender.send(e||fa,n,s)}terminate(){if(this.readyState!==a.CLOSED){if(this.readyState===a.CONNECTING){it(this,this._req,"WebSocket was closed before the connection was established");return}this._socket&&(this._readyState=a.CLOSING,this._socket.destroy())}}};Object.defineProperty(he,"CONNECTING",{enumerable:!0,value:Ft.indexOf("CONNECTING")});Object.defineProperty(he.prototype,"CONNECTING",{enumerable:!0,value:Ft.indexOf("CONNECTING")});Object.defineProperty(he,"OPEN",{enumerable:!0,value:Ft.indexOf("OPEN")});Object.defineProperty(he.prototype,"OPEN",{enumerable:!0,value:Ft.indexOf("OPEN")});Object.defineProperty(he,"CLOSING",{enumerable:!0,value:Ft.indexOf("CLOSING")});Object.defineProperty(he.prototype,"CLOSING",{enumerable:!0,value:Ft.indexOf("CLOSING")});Object.defineProperty(he,"CLOSED",{enumerable:!0,value:Ft.indexOf("CLOSED")});Object.defineProperty(he.prototype,"CLOSED",{enumerable:!0,value:Ft.indexOf("CLOSED")});["binaryType","bufferedAmount","extensions","isPaused","protocol","readyState","url"].forEach(a=>{Object.defineProperty(he.prototype,a,{enumerable:!0})});["open","error","close","message"].forEach(a=>{Object.defineProperty(he.prototype,`on${a}`,{enumerable:!0,get(){for(let e of this.listeners(a))if(e[Rr])return e[qu];return null},set(e){for(let t of this.listeners(a))if(t[Rr]){this.removeListener(a,t);break}typeof e=="function"&&this.addEventListener(a,e,{[Rr]:!0})}})});he.prototype.addEventListener=Gu;he.prototype.removeEventListener=Vu;bl.exports=he;function ml(a,e,t,s){let n={allowSynchronousEvents:!0,autoPong:!0,closeTimeout:Hu,protocolVersion:Ir[1],maxBufferedChunks:1048576,maxFragments:131072,maxPayload:104857600,skipUTF8Validation:!1,perMessageDeflate:!0,followRedirects:!1,maxRedirects:10,...s,socketPath:void 0,hostname:void 0,protocol:void 0,timeout:void 0,method:"GET",host:void 0,path:void 0,port:void 0};if(a._autoPong=n.autoPong,a._closeTimeout=n.closeTimeout,!Ir.includes(n.protocolVersion))throw new RangeError(`Unsupported protocol version: ${n.protocolVersion} (supported versions: ${Ir.join(", ")})`);let r;if(e instanceof Dr)r=e;else try{r=new Dr(e)}catch{throw new SyntaxError(`Invalid URL: ${e}`)}r.protocol==="http:"?r.protocol="ws:":r.protocol==="https:"&&(r.protocol="wss:"),a._url=r.href;let i=r.protocol==="wss:",o=r.protocol==="ws+unix:",l;if(r.protocol!=="ws:"&&!i&&!o?l=`The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`:o&&!r.pathname?l="The URL's pathname is empty":r.hash&&(l="The URL contains a fragment identifier"),l){let f=new SyntaxError(l);if(a._redirects===0)throw f;ga(a,f);return}let c=i?443:80,d=Nu(16).toString("base64"),u=i?Fu.request:Lu.request,h=new Set,p;if(n.createConnection=n.createConnection||(i?Zu:Qu),n.defaultPort=n.defaultPort||c,n.port=r.port||c,n.host=r.hostname.startsWith("[")?r.hostname.slice(1,-1):r.hostname,n.headers={...n.headers,"Sec-WebSocket-Version":n.protocolVersion,"Sec-WebSocket-Key":d,Connection:"Upgrade",Upgrade:"websocket"},n.path=r.pathname+r.search,n.timeout=n.handshakeTimeout,n.perMessageDeflate&&(p=new jt({...n.perMessageDeflate,isServer:!1,maxPayload:n.maxPayload}),n.headers["Sec-WebSocket-Extensions"]=Yu({[jt.extensionName]:p.offer()})),t.length){for(let f of t){if(typeof f!="string"||!Xu.test(f)||h.has(f))throw new SyntaxError("An invalid or duplicated subprotocol was specified");h.add(f)}n.headers["Sec-WebSocket-Protocol"]=t.join(",")}if(n.origin&&(n.protocolVersion<13?n.headers["Sec-WebSocket-Origin"]=n.origin:n.headers.Origin=n.origin),(r.username||r.password)&&(n.auth=`${r.username}:${r.password}`),o){let f=n.path.split(":");n.socketPath=f[0],n.path=f[1]}let m;if(n.followRedirects){if(a._redirects===0){a._originalIpc=o,a._originalSecure=i,a._originalHostOrSocketPath=o?n.socketPath:r.host;let f=s&&s.headers;if(s={...s,headers:{}},f)for(let[g,w]of Object.entries(f))s.headers[g.toLowerCase()]=w}else if(a.listenerCount("redirect")===0){let f=o?a._originalIpc?n.socketPath===a._originalHostOrSocketPath:!1:a._originalIpc?!1:r.host===a._originalHostOrSocketPath;(!f||a._originalSecure&&!i)&&(delete n.headers.authorization,delete n.headers.cookie,f||delete n.headers.host,n.auth=void 0)}n.auth&&!s.headers.authorization&&(s.headers.authorization="Basic "+Buffer.from(n.auth).toString("base64")),m=a._req=u(n),a._redirects&&a.emit("redirect",a.url,m)}else m=a._req=u(n);n.timeout&&m.on("timeout",()=>{it(a,m,"Opening handshake has timed out")}),m.on("error",f=>{m===null||m[pl]||(m=a._req=null,ga(a,f))}),m.on("response",f=>{let g=f.headers.location,w=f.statusCode;if(g&&n.followRedirects&&w>=300&&w<400){if(++a._redirects>n.maxRedirects){it(a,m,"Maximum redirects exceeded");return}m.abort();let y;try{y=new Dr(g,e)}catch{let k=new SyntaxError(`Invalid URL: ${g}`);ga(a,k);return}ml(a,y,t,s)}else a.emit("unexpected-response",m,f)||it(a,m,`Unexpected server response: ${f.statusCode}`)}),m.on("upgrade",(f,g,w)=>{if(a.emit("upgrade",f),a.readyState!==he.CONNECTING)return;m=a._req=null;let y=f.headers.upgrade;if(y===void 0||y.toLowerCase()!=="websocket"){it(a,g,"Invalid Upgrade header");return}let v=Bu("sha1").update(d+Wu).digest("base64");if(f.headers["sec-websocket-accept"]!==v){it(a,g,"Invalid Sec-WebSocket-Accept header");return}let k=f.headers["sec-websocket-protocol"],b;if(k!==void 0?h.size?h.has(k)||(b="Server sent an invalid subprotocol"):b="Server sent a subprotocol but none was requested":h.size&&(b="Server sent no subprotocol"),b){it(a,g,b);return}k&&(a._protocol=k);let P=f.headers["sec-websocket-extensions"];if(P!==void 0){if(!p){it(a,g,"Server sent a Sec-WebSocket-Extensions header but no extension was requested");return}let A;try{A=Ku(P)}catch{it(a,g,"Invalid Sec-WebSocket-Extensions header");return}let M=Object.keys(A);if(M.length!==1||M[0]!==jt.extensionName){it(a,g,"Server indicated an extension that was not requested");return}try{p.accept(A[jt.extensionName])}catch{it(a,g,"Invalid Sec-WebSocket-Extensions header");return}a._extensions[jt.extensionName]=p}a.setSocket(g,w,{allowSynchronousEvents:n.allowSynchronousEvents,generateMask:n.generateMask,maxBufferedChunks:n.maxBufferedChunks,maxFragments:n.maxFragments,maxPayload:n.maxPayload,skipUTF8Validation:n.skipUTF8Validation})}),n.finishRequest?n.finishRequest(m,a):m.end()}function ga(a,e){a._readyState=he.CLOSING,a._errorEmitted=!0,a.emit("error",e),a.emitClose()}function Qu(a){return a.path=a.socketPath,ul.connect(a)}function Zu(a){return a.path=void 0,!a.servername&&a.servername!==""&&(a.servername=ul.isIP(a.host)?"":a.host),Ou.connect(a)}function it(a,e,t){a._readyState=he.CLOSING;let s=new Error(t);Error.captureStackTrace(s,it),e.setHeader?(e[pl]=!0,e.abort(),e.socket&&!e.socket.destroyed&&e.socket.destroy(),process.nextTick(ga,a,s)):(e.destroy(s),e.once("error",a.emit.bind(a,"error")),e.once("close",a.emitClose.bind(a)))}function Mr(a,e,t){if(e){let s=ju(e)?e.size:Ju(e).length;a._socket?a._sender._bufferedBytes+=s:a._bufferedAmount+=s}if(t){let s=new Error(`WebSocket is not open: readyState ${a.readyState} (${Ft[a.readyState]})`);process.nextTick(t,s)}}function eh(a,e){let t=this[Me];t._closeFrameReceived=!0,t._closeMessage=e,t._closeCode=a,t._socket[Me]!==void 0&&(t._socket.removeListener("data",ya),process.nextTick(fl,t._socket),a===1005?t.close():t.close(a,e))}function th(){let a=this[Me];a.isPaused||a._socket.resume()}function sh(a){let e=this[Me];e._socket[Me]!==void 0&&(e._socket.removeListener("data",ya),process.nextTick(fl,e._socket),e.close(a[zu])),e._errorEmitted||(e._errorEmitted=!0,e.emit("error",a))}function dl(){this[Me].emitClose()}function nh(a,e){this[Me].emit("message",a,e)}function ah(a){let e=this[Me];e._autoPong&&e.pong(a,!this._isServer,hl),e.emit("ping",a)}function rh(a){this[Me].emit("pong",a)}function fl(a){a.resume()}function ih(a){let e=this[Me];e.readyState!==he.CLOSED&&(e.readyState===he.OPEN&&(e._readyState=he.CLOSING,gl(e)),this._socket.end(),e._errorEmitted||(e._errorEmitted=!0,e.emit("error",a)))}function gl(a){a._closeTimer=setTimeout(a._socket.destroy.bind(a._socket),a._closeTimeout)}function yl(){let a=this[Me];if(this.removeListener("close",yl),this.removeListener("data",ya),this.removeListener("end",vl),a._readyState=he.CLOSING,!this._readableState.endEmitted&&!a._closeFrameReceived&&!a._receiver._writableState.errorEmitted&&this._readableState.length!==0){let e=this.read(this._readableState.length);a._receiver.write(e)}a._receiver.end(),this[Me]=void 0,clearTimeout(a._closeTimer),a._receiver._writableState.finished||a._receiver._writableState.errorEmitted?a.emitClose():(a._receiver.on("error",dl),a._receiver.on("finish",dl))}function ya(a){this[Me]._receiver.write(a)||this.pause()}function vl(){let a=this[Me];a._readyState=he.CLOSING,a._receiver.end(),this.end()}function wl(){let a=this[Me];this.removeListener("error",wl),this.on("error",hl),a&&(a._readyState=he.CLOSING,this.destroy())}});var Cl=tt((bg,Sl)=>{"use strict";var wg=va(),{Duplex:oh}=require("stream");function kl(a){a.emit("close")}function lh(){!this.destroyed&&this._writableState.finished&&this.destroy()}function xl(a){this.removeListener("error",xl),this.destroy(),this.listenerCount("error")===0&&this.emit("error",a)}function ch(a,e){let t=!0,s=new oh({...e,autoDestroy:!1,emitClose:!1,objectMode:!1,writableObjectMode:!1});return a.on("message",function(r,i){let o=!i&&s._readableState.objectMode?r.toString():r;s.push(o)||a.pause()}),a.once("error",function(r){s.destroyed||(t=!1,s.destroy(r))}),a.once("close",function(){s.destroyed||s.push(null)}),s._destroy=function(n,r){if(a.readyState===a.CLOSED){r(n),process.nextTick(kl,s);return}let i=!1;a.once("error",function(l){i=!0,r(l)}),a.once("close",function(){i||r(n),process.nextTick(kl,s)}),t&&a.terminate()},s._final=function(n){if(a.readyState===a.CONNECTING){a.once("open",function(){s._final(n)});return}a._socket!==null&&(a._socket._writableState.finished?(n(),s._readableState.endEmitted&&s.destroy()):(a._socket.once("finish",function(){n()}),a.close()))},s._read=function(){a.isPaused&&a.resume()},s._write=function(n,r,i){if(a.readyState===a.CONNECTING){a.once("open",function(){s._write(n,r,i)});return}a.send(n,i)},s.on("end",lh),s.on("error",xl),s}Sl.exports=ch});var Fr=tt((kg,Tl)=>{"use strict";var{tokenChars:dh}=xs();function uh(a){let e=new Set,t=-1,s=-1,n=0;for(n;n{"use strict";var hh=require("events"),wa=require("http"),{Duplex:xg}=require("stream"),{createHash:ph}=require("crypto"),_l=ma(),is=ks(),mh=Fr(),fh=va(),{CLOSE_TIMEOUT:gh,GUID:yh,kWebSocket:vh}=Rt(),wh=/^[+/0-9A-Za-z]{22}==$/,Al=0,Pl=1,Dl=2,Lr=class extends hh{constructor(e,t){if(super(),e={allowSynchronousEvents:!0,autoPong:!0,maxBufferedChunks:1024*1024,maxFragments:128*1024,maxPayload:100*1024*1024,skipUTF8Validation:!1,perMessageDeflate:!1,handleProtocols:null,clientTracking:!0,closeTimeout:gh,verifyClient:null,noServer:!1,backlog:null,server:null,host:null,path:null,port:null,WebSocket:fh,...e},e.port==null&&!e.server&&!e.noServer||e.port!=null&&(e.server||e.noServer)||e.server&&e.noServer)throw new TypeError('One and only one of the "port", "server", or "noServer" options must be specified');if(e.port!=null?(this._server=wa.createServer((s,n)=>{let r=wa.STATUS_CODES[426];n.writeHead(426,{"Content-Length":r.length,"Content-Type":"text/plain"}),n.end(r)}),this._server.listen(e.port,e.host,e.backlog,t)):e.server&&(this._server=e.server),this._server){let s=this.emit.bind(this,"connection");this._removeListeners=bh(this._server,{listening:this.emit.bind(this,"listening"),error:this.emit.bind(this,"error"),upgrade:(n,r,i)=>{this.handleUpgrade(n,r,i,s)}})}e.perMessageDeflate===!0&&(e.perMessageDeflate={}),e.clientTracking&&(this.clients=new Set,this._shouldEmitClose=!1),this.options=e,this._state=Al}address(){if(this.options.noServer)throw new Error('The server is operating in "noServer" mode');return this._server?this._server.address():null}close(e){if(this._state===Dl){e&&this.once("close",()=>{e(new Error("The server is not running"))}),process.nextTick(an,this);return}if(e&&this.once("close",e),this._state!==Pl)if(this._state=Pl,this.options.noServer||this.options.server)this._server&&(this._removeListeners(),this._removeListeners=this._server=null),this.clients?this.clients.size?this._shouldEmitClose=!0:process.nextTick(an,this):process.nextTick(an,this);else{let t=this._server;this._removeListeners(),this._removeListeners=this._server=null,t.close(()=>{an(this)})}}shouldHandle(e){if(this.options.path){let t=e.url.indexOf("?");if((t!==-1?e.url.slice(0,t):e.url)!==this.options.path)return!1}return!0}handleUpgrade(e,t,s,n){t.on("error",El);let r=e.headers["sec-websocket-key"],i=e.headers.upgrade,o=+e.headers["sec-websocket-version"];if(e.method!=="GET"){os(this,e,t,405,"Invalid HTTP method");return}if(i===void 0||i.toLowerCase()!=="websocket"){os(this,e,t,400,"Invalid Upgrade header");return}if(r===void 0||!wh.test(r)){os(this,e,t,400,"Missing or invalid Sec-WebSocket-Key header");return}if(o!==13&&o!==8){os(this,e,t,400,"Missing or invalid Sec-WebSocket-Version header",{"Sec-WebSocket-Version":"13, 8"});return}if(!this.shouldHandle(e)){rn(t,400);return}let l=e.headers["sec-websocket-protocol"],c=new Set;if(l!==void 0)try{c=mh.parse(l)}catch{os(this,e,t,400,"Invalid Sec-WebSocket-Protocol header");return}let d=e.headers["sec-websocket-extensions"],u={};if(this.options.perMessageDeflate&&d!==void 0){let h=new is({...this.options.perMessageDeflate,isServer:!0,maxPayload:this.options.maxPayload});try{let p=_l.parse(d);p[is.extensionName]&&(h.accept(p[is.extensionName]),u[is.extensionName]=h)}catch{os(this,e,t,400,"Invalid or unacceptable Sec-WebSocket-Extensions header");return}}if(this.options.verifyClient){let h={origin:e.headers[`${o===8?"sec-websocket-origin":"origin"}`],secure:!!(e.socket.authorized||e.socket.encrypted),req:e};if(this.options.verifyClient.length===2){this.options.verifyClient(h,(p,m,f,g)=>{if(!p)return rn(t,m||401,f,g);this.completeUpgrade(u,r,c,e,t,s,n)});return}if(!this.options.verifyClient(h))return rn(t,401)}this.completeUpgrade(u,r,c,e,t,s,n)}completeUpgrade(e,t,s,n,r,i,o){if(!r.readable||!r.writable)return r.destroy();if(r[vh])throw new Error("server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration");if(this._state>Al)return rn(r,503);let c=["HTTP/1.1 101 Switching Protocols","Upgrade: websocket","Connection: Upgrade",`Sec-WebSocket-Accept: ${ph("sha1").update(t+yh).digest("base64")}`],d=new this.options.WebSocket(null,void 0,this.options);if(s.size){let u=this.options.handleProtocols?this.options.handleProtocols(s,n):s.values().next().value;u&&(c.push(`Sec-WebSocket-Protocol: ${u}`),d._protocol=u)}if(e[is.extensionName]){let u=e[is.extensionName].params,h=_l.format({[is.extensionName]:[u]});c.push(`Sec-WebSocket-Extensions: ${h}`),d._extensions=e}this.emit("headers",c,n),r.write(c.concat(`\r `).join(`\r -`)),a.removeListener("error",Ao),h.setSocket(a,i,{allowSynchronousEvents:this.options.allowSynchronousEvents,maxBufferedChunks:this.options.maxBufferedChunks,maxFragments:this.options.maxFragments,maxPayload:this.options.maxPayload,skipUTF8Validation:this.options.skipUTF8Validation}),this.clients&&(this.clients.add(h),h.on("close",()=>{this.clients.delete(h),this._shouldEmitClose&&!this.clients.size&&process.nextTick(Ds,this)})),o(h,n)}};Po.exports=qa;function jd(r,t){for(let e of Object.keys(t))r.on(e,t[e]);return function(){for(let s of Object.keys(t))r.removeListener(s,t[s])}}function Ds(r){r._state=Eo,r.emit("close")}function Ao(){this.destroy()}function Is(r,t,e,s){e=e||Fn.STATUS_CODES[t],s={Connection:"close","Content-Type":"text/html","Content-Length":Buffer.byteLength(e),...s},r.once("finish",r.destroy),r.end(`HTTP/1.1 ${t} ${Fn.STATUS_CODES[t]}\r +`)),r.removeListener("error",El),d.setSocket(r,i,{allowSynchronousEvents:this.options.allowSynchronousEvents,maxBufferedChunks:this.options.maxBufferedChunks,maxFragments:this.options.maxFragments,maxPayload:this.options.maxPayload,skipUTF8Validation:this.options.skipUTF8Validation}),this.clients&&(this.clients.add(d),d.on("close",()=>{this.clients.delete(d),this._shouldEmitClose&&!this.clients.size&&process.nextTick(an,this)})),o(d,n)}};Rl.exports=Lr;function bh(a,e){for(let t of Object.keys(e))a.on(t,e[t]);return function(){for(let s of Object.keys(e))a.removeListener(s,e[s])}}function an(a){a._state=Dl,a.emit("close")}function El(){this.destroy()}function rn(a,e,t,s){t=t||wa.STATUS_CODES[e],s={Connection:"close","Content-Type":"text/html","Content-Length":Buffer.byteLength(t),...s},a.once("finish",a.destroy),a.end(`HTTP/1.1 ${e} ${wa.STATUS_CODES[e]}\r `+Object.keys(s).map(n=>`${n}: ${s[n]}`).join(`\r `)+`\r \r -`+e)}function Nt(r,t,e,s,n,a){if(r.listenerCount("wsClientError")){let i=new Error(n);Error.captureStackTrace(i,Nt),r.emit("wsClientError",i,e,t)}else Is(e,s,n,a)}});var Ah={};dl(Ah,{default:()=>zn});module.exports=hl(Ah);var Os=require("fs"),Xa=require("os"),Qa=require("path"),Se=require("obsidian");var jt="agent-fleet-agents";var _t="agent-fleet-dashboard",rt="agent-fleet-chat",it={fleetFolder:"_fleet",claudeCliPath:"claude",codexCliPath:"codex",defaultModel:"default",awsRegion:"us-east-1",maxConcurrentRuns:2,runLogRetentionDays:30,catchUpMissedTasks:!0,notificationLevel:"all",showStatusBar:!0,mcpApiKeys:{},mcpTokens:{},channelCredentials:{},maxConcurrentChannelSessions:5,channelIdleTimeoutMinutes:15,channelRateLimitPerConversation:20,channelRateLimitWindowMinutes:5,chatWatchdogMinutes:10,defaultFileHashes:{}},rr=["agents","skills","tasks","runs","memory","channels","mcp","usage"],js=1500,ir="0 3 * * *",or=3;var Mr=require("path"),x=require("obsidian");var Jn=[{path:"agents/fleet-orchestrator/CONTEXT.md",content:`--- +`+t)}function os(a,e,t,s,n,r){if(a.listenerCount("wsClientError")){let i=new Error(n);Error.captureStackTrace(i,os),a.emit("wsClientError",i,t,e)}else rn(t,s,n,r)}});var bp={};jc(bp,{default:()=>Ra});module.exports=Hc(bp);var hn=require("fs"),Ms=require("fs/promises"),pn=require("os"),Is=require("path"),pe=require("obsidian");var us="agent-fleet-agents";var Yt="agent-fleet-dashboard",mt="agent-fleet-chat",ft={fleetFolder:"_fleet",claudeCliPath:"claude",codexCliPath:"codex",defaultModel:"default",awsRegion:"us-east-1",maxConcurrentRuns:2,runLogRetentionDays:30,catchUpMissedTasks:!0,notificationLevel:"all",showStatusBar:!0,mcpApiKeys:{},mcpTokens:{},channelCredentials:{},maxConcurrentChannelSessions:5,channelIdleTimeoutMinutes:15,channelRateLimitPerConversation:20,channelRateLimitWindowMinutes:5,chatWatchdogMinutes:10,defaultFileHashes:{}},Xr=["agents","skills","tasks","runs","memory","channels","mcp","usage"],bn=1500,Qr="0 3 * * *",Zr=3;var Et=require("obsidian");var Ls=[{path:"agents/fleet-orchestrator/CONTEXT.md",content:`--- {} --- @@ -704,6 +704,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: \`@agent-name\` - Agents with \`approval_required\` set cannot be bound to a channel - Multi-agent routing: type \`@agent-name: message\` to switch agents, or use \`/agents\` for interactive picker - Obsidian must be running for channels to work \u2014 when closed, bots go offline +- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn \u2014 matching the in-app chat. Each injected follow-up gets its own reply; \`[REMEMBER]\` blocks are stripped from channel replies. ## Creating a Task @@ -11629,50 +11630,13 @@ python scripts/office/validate.py [--original ] [--auto-re - \`paraId\`/\`durableId\` values that exceed OOXML limits - Missing \`xml:space="preserve"\` on \`w:t\` elements with whitespace -`}];var Ws=require("obsidian");function J(r){let t=r.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);if(!t)return{frontmatter:{},body:r.trim()};let e=t[1]??"",s=t[2]??"",n;try{n=(0,Ws.parseYaml)(e)??{}}catch(a){console.warn("Agent Fleet: malformed YAML frontmatter, treating as empty",a),n={}}return{frontmatter:n,body:s.trim()}}function H(r,t){let e=(0,Ws.stringifyYaml)(r).trim(),s=t.trim();return`--- -${e} +`}];var hs=require("obsidian");function Tt(a){return typeof a=="object"&&a!==null}function C(a){return typeof a=="string"?a:void 0}function Ie(a,e){return typeof a=="boolean"?a:e}function Pe(a,e){return typeof a=="number"&&Number.isFinite(a)?a:e}function Q(a){return Array.isArray(a)?a.filter(e=>typeof e=="string"):[]}function Na(a){if(!Tt(a))return;let e={};for(let[t,s]of Object.entries(a))typeof s=="string"&&(e[t]=s);return Object.keys(e).length>0?e:void 0}async function fe(a,e){if(!a.getAbstractFileByPath(e))try{await a.createFolder(e)}catch(t){if(!(t instanceof Error?t.message:String(t)).includes("Folder already exists"))throw t}}async function We(a,e,t){if(!a.getAbstractFileByPath(e))try{await a.create(e,t)}catch(s){if(!(s instanceof Error?s.message:String(s)).includes("File already exists"))throw s}}async function qe(a,e){let t=a.vault.getAbstractFileByPath(e);t&&await a.fileManager.trashFile(t)}function ps(a,e){for(let t of a.children)t instanceof hs.TFile&&t.extension==="md"&&e.push(t),t instanceof hs.TFolder&&ps(t,e)}async function _t(a,e,t){let s=0;for(;;){let n=s===0?"":`-${s+1}`,r=(0,hs.normalizePath)(`${e}/${t}${n}.md`);if(!a.getAbstractFileByPath(r))return r;s+=1}}var nt=require("obsidian");var kn=require("obsidian");function W(a){let e=a.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);if(!e)return{frontmatter:{},body:a.trim()};let t=e[1]??"",s=e[2]??"",n;try{n=(0,kn.parseYaml)(t)??{}}catch(r){console.warn("Agent Fleet: malformed YAML frontmatter, treating as empty",r),n={}}return{frontmatter:n,body:s.trim()}}function U(a,e){let t=(0,kn.stringifyYaml)(a).trim(),s=e.trim();return`--- +${t} --- ${s} -`}function oe(r){return r.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"")}function Wt(r,t){return r.length<=t?r:`${r.slice(0,t-1)}\u2026`}var Hs=2,dr=["Preferences","Procedures","Observations","Recent"],hr="Recent",Qn=/\[REMEMBER(?::([a-zA-Z]+))?\]([\s\S]*?)\[\/REMEMBER\]/g;function ul(r){switch((r??"").toLowerCase()){case"pin":case"preference":case"preferences":return{pinned:!0,section:"Preferences"};case"procedure":case"procedures":return{pinned:!1,section:"Procedures"};case"observation":case"observations":return{pinned:!1,section:"Observations"};default:return{pinned:!1}}}function qs(r){let t=[];for(let e of r.matchAll(Qn)){let s=(e[2]??"").trim();if(!s)continue;let n=ul(e[1]);t.push({text:s,pinned:n.pinned,section:n.section})}return t}function At(r){return r.replace(Qn,"").replace(/[ \t]+\n/g,` -`).replace(/\n{3,}/g,` - -`).trim()}function ur(r){let t=r.replace(Qn,""),e=t.match(/\[REMEMBER(?::[a-zA-Z]+)?\][\s\S]*$/i);if(e&&e.index!==void 0)return t.slice(0,e.index);let s=t.match(/\[(?:R(?:E(?:M(?:E(?:M(?:B(?:E(?:R(?::[a-zA-Z]*)?)?)?)?)?)?)?)?)?$/i);return s&&s.index!==void 0?t.slice(0,s.index):t}var pl={Preferences:"Preferences",Procedures:"Procedures",Observations:"Observations",Recent:"Recent (uncurated)"};function ml(r){let t=r.trim().toLowerCase();return t.startsWith("preference")?"Preferences":t.startsWith("procedure")?"Procedures":t.startsWith("observation")?"Observations":t.startsWith("recent")?"Recent":"Observations"}function us(r){return Math.ceil(r.length/4)}var lr=500;function pr(r){let t=r.replace(/\s+/g," ").trim();return t.length<=lr?t:`${t.slice(0,lr-1).trimEnd()}\u2026`}function Xn(r){return typeof r=="string"?r:void 0}function fl(r,t){return typeof r=="number"&&Number.isFinite(r)?r:t}function mr(r,t){return{filePath:r,agent:t,schema:Hs,tokenEstimate:0,sections:[]}}var gl=/\s*\s*$/,cr=/^\[pin\]\s+/i;function yl(r){let t=r.match(/^[-*]\s+(.*)$/);if(!t)return null;let e=(t[1]??"").trim();if(!e)return null;let s,n,a=e.match(gl);if(a){e=e.slice(0,a.index).trim();let o=(a[1]??"").trim(),c=o.match(/^src:(\S+)(?:\s+(.+))?$/);c?(s=c[1],n=c[2]?.trim()||void 0):o&&(n=o)}let i=!1;return cr.test(e)&&(i=!0,e=e.replace(cr,"").trim()),e?{text:e,source:s,date:n,pinned:i}:null}function fr(r){let t=r.pinned?"[pin] ":"",e="";if(r.source||r.date){let s=[];r.source&&s.push(`src:${r.source}`),r.date&&s.push(r.date),e=` `}return`- ${t}${r.text}${e}`}function Ke(r){return vl(r).map(e=>{let s=e.entries.map(fr).join(` -`);return`## ${pl[e.name]} -${s}`}).join(` - -`)}function vl(r){let t=[];for(let e of dr){let s=r.find(n=>n.name===e);s&&s.entries.length>0&&t.push(s)}return t}function gr(r,t,e){let{frontmatter:s,body:n}=J(r),a=zs(n);return{filePath:t,agent:Xn(s.agent)??e,schema:fl(s.schema,Hs),lastUpdated:Xn(s.last_updated),lastReflection:Xn(s.last_reflection),tokenEstimate:us(Ke(a)),sections:a}}function zs(r){let t=new Map,e=(a,i)=>{let o=t.get(a)??[];o.push(i),t.set(a,o)},s="Observations";for(let a of r.split(` -`)){let i=a.match(/^#{1,6}\s+(.+?)\s*$/);if(i){s=ml(i[1]??"");continue}let o=yl(a);o&&e(s,o)}let n=[];for(let a of dr){let i=t.get(a);i&&i.length&&n.push({name:a,entries:i})}return n}function yr(r){let t=Ke(r.sections),e={agent:r.agent,schema:r.schema||Hs,last_updated:r.lastUpdated??"",token_estimate:us(t)};return r.lastReflection&&(e.last_reflection=r.lastReflection),H(e,t||"## Observations")}function vr(r,t,e,s){if(t.length===0)return r;let n=r.sections.map(o=>({name:o.name,entries:[...o.entries]})),a=n.find(o=>o.name===e);return a||(a={name:e,entries:[]},n.push(a)),a.entries.push(...t),{...r,sections:n,lastUpdated:s??r.lastUpdated,tokenEstimate:us(Ke(n))}}var wl=["Recent","Observations","Procedures"];function wr(r,t){if(r.tokenEstimate<=t)return{wm:r,spilled:[]};let e=r.sections.map(o=>({name:o.name,entries:[...o.entries]})),s=[],n=Ke(e).length,a=()=>Math.ceil(n/4)>t;for(let o of wl){if(!a())break;let c=e.find(l=>l.name===o);if(c)for(;c.entries.length>0&&a();){let l=c.entries.findIndex(d=>!d.pinned);if(l===-1)break;let h=c.entries.splice(l,1)[0];h&&(s.push(h),n-=fr(h).length+1)}}let i=e.filter(o=>o.entries.length>0);return{wm:{...r,sections:i,tokenEstimate:us(Ke(i))},spilled:s}}function Zn(r,t,e,s){let n=zs(r);return{filePath:t,agent:e,schema:Hs,lastUpdated:s,tokenEstimate:us(Ke(n)),sections:n}}function br(r,t){let e=(r?.sections??[]).flatMap(i=>i.entries).filter(i=>i.pinned);if(e.length===0||t.some(i=>i.entries.some(o=>o.pinned)))return t;let n=t.map(i=>({name:i.name,entries:[...i.entries]})),a=n.find(i=>i.name==="Preferences");return a||(a={name:"Preferences",entries:[]},n.unshift(a)),a.entries.unshift(...e),n}var bl="When you learn a durable fact about the user, their preferences, or how to do your work better, save it to memory. Prefer the `remember` tool \u2014 call remember(fact, pin?, section?); it is the reliable way to record a memory. If that tool is not available, fall back to writing [REMEMBER] [/REMEMBER] in your reply (use [REMEMBER:pin] for standing preferences and hard constraints). Record only durable, reusable facts \u2014 not transient task details. These notes persist into your future runs.";function Gs(r,t){if(!r.memory)return"";let s=(t?Ke(t.sections).trim():"")||"Nothing yet \u2014 this is a fresh agent.";return`## Memory -${bl} - -### What you've learned so far -${s}`}var mt=require("child_process"),kr=require("fs"),Ys=require("os"),Et=require("path");function ea(){return(0,Ys.homedir)()}function xr(){if(process.platform==="darwin")return"/bin/zsh";for(let r of["/bin/bash","/bin/zsh","/bin/sh"])if((0,kr.existsSync)(r))return r;return"/bin/sh"}function kl(r){return`'${r.replace(/'/g,"'\\''")}'`}function dt(r,t,e){let s={cwd:e?.cwd,env:e?.env};if(process.platform==="win32")return(0,mt.spawn)(r,t,s);let n=xr(),a=[r,...t].map(kl).join(" ");return(0,mt.spawn)(n,["-l","-c",a],s)}function Sr(r,t){let e={cwd:t?.cwd,env:t?.env,stdio:["pipe","pipe","pipe"]};if(process.platform==="win32")return(0,mt.spawn)(r,[],{...e,shell:!0});let s=xr();return(0,mt.spawn)(s,["-l","-c",r],e)}function Cr(r){try{require("electron").shell.openExternal(r)}catch{switch(process.platform){case"darwin":(0,mt.spawn)("open",[r],{stdio:"ignore"});break;case"win32":(0,mt.spawn)("cmd.exe",["/c","start","",r.replace(/&/g,"^&")],{stdio:"ignore"});break;default:(0,mt.spawn)("xdg-open",[r],{stdio:"ignore"});break}}}function ye(r){return r.split(/\r?\n/)}function Tr(r){let t=(0,Ys.homedir)();if(process.platform==="win32")return[r,(0,Et.join)(process.env.APPDATA??"","Claude","claude.exe"),(0,Et.join)(process.env.LOCALAPPDATA??"","Claude","claude.exe"),(0,Et.join)(t,".local","bin","claude.exe"),"claude.exe","claude"].filter(s=>!!s&&Vs(s));let e=[r,(0,Et.join)(t,".local","bin","claude")];return process.platform==="darwin"&&e.push("/opt/homebrew/bin/claude"),e.push("/usr/local/bin/claude","/usr/bin/claude","claude"),e.filter(s=>!!s&&Vs(s))}function ta(r){let t=(0,Ys.homedir)();if(process.platform==="win32")return[r,(0,Et.join)(t,".local","bin","codex.exe"),"codex.exe","codex"].filter(s=>!!s&&Vs(s));let e=[r,(0,Et.join)(t,".local","bin","codex")];return process.platform==="darwin"&&e.push("/opt/homebrew/bin/codex"),e.push("/usr/local/bin/codex","/usr/bin/codex","codex"),e.filter(s=>!!s&&Vs(s))}function Vs(r){return!r||/[\n\r\0]/.test(r)?!1:r.startsWith("/")?/^[\w/.@+-]+$/.test(r):r.startsWith("~")?/^~[\w/.@+-]*$/.test(r):/^[a-zA-Z]:[\\/]/.test(r)?/^[a-zA-Z]:[\\/][\w\\/. @+-]+$/.test(r):r.startsWith("\\\\")?/^\\\\[\w\\/. @+-]+$/.test(r):!r.includes("/")&&!r.includes("\\")?/^[\w.@+-]+$/.test(r):!1}function sa(r){return!!(r.includes("/")||r.includes("\\"))}function _r(r){let t=new Map;for(let s of r){if(typeof s.costUsd!="number")continue;let n=t.get(s.agent);n?n.push(s):t.set(s.agent,[s])}let e=0;for(let s of t.values()){s.sort((a,i)=>a.ts.localeCompare(i.ts));let n=0;for(let a of s){let i=a.costUsd,o=i>=n?i-n:i;n=i,o!==i&&e++,a.costUsd=o}}return e}function Ht(r){return typeof r=="object"&&r!==null}function A(r){return typeof r=="string"?r:void 0}function Be(r,t){return typeof r=="boolean"?r:t}function Re(r,t){return typeof r=="number"&&Number.isFinite(r)?r:t}function ue(r){return Array.isArray(r)?r.filter(t=>typeof t=="string"):[]}function Ar(r){if(!Ht(r))return;let t={};for(let[e,s]of Object.entries(r))typeof s=="string"&&(t[e]=s);return Object.keys(t).length>0?t:void 0}var Er=!1;function Pr(r,t={}){let e=r.memory_token_budget??t.memory_token_budget;return e!==void 0?Re(e,js):((r.memory_max_entries??t.memory_max_entries)!==void 0&&!Er&&(Er=!0,console.info(`Agent Fleet: \`memory_max_entries\` is deprecated and no longer enforced; memory is now bounded by \`memory_token_budget\` (default ${js}). Set that field to tune memory size.`)),js)}function Rr(r,t={}){let e=s=>r[s]??t[s];return{enabled:Be(e("reflection_enabled"),!1),schedule:A(e("reflection_schedule"))??ir,recurrenceThreshold:Re(e("reflection_recurrence_threshold"),or),proposeSkills:Be(e("reflection_propose_skills"),!1),model:A(e("reflection_model"))}}function Dr(r){return(0,x.normalizePath)(r.replace(/\.md$/,".permissions.json"))}function Ir(r){let t=0;for(let e=0;ee.path.startsWith(`${this.getFleetRoot()}/`));for(let e of t)await this.loadFile(e);return this.validateReferences(),this.getSnapshot()}async loadFile(t){let e=typeof t=="string"?this.vault.getAbstractFileByPath(t):t;if(!(e instanceof x.TFile)||e.extension!=="md")return;if(this.isInsideAgentFolder(e.path)){await this.reloadFolderAgentContaining(e.path);return}if(this.isInsideSkillFolder(e.path)){await this.reloadFolderSkillContaining(e.path);return}this.clearStoredFile(e.path);let s=`${this.getSubfolder("channels")}/`;if(e.path.startsWith(s)){if(!e.path.slice(s.length).includes("/")){let c=await this.vault.cachedRead(e),l=this.parseChannelFile(e.path,c);l&&this.channels.set(e.path,l)}return}let n=`${this.getSubfolder("mcp")}/`;if(e.path.startsWith(n)){if(!e.path.slice(n.length).includes("/")){let c=await this.vault.cachedRead(e),l=this.parseMcpServerFile(e.path,c);l&&this.mcpServers.set(e.path,l)}return}let a=await this.vault.cachedRead(e),i=this.parseFile(e.path,a);if(i)if("taskId"in i)this.tasks.set(e.path,i);else if("model"in i){if(!i.isFolder){let o=Dr(e.path),c=this.vault.getAbstractFileByPath(o);if(c instanceof x.TFile)try{let l=await this.vault.cachedRead(c),h=JSON.parse(l);i.permissionRules={allow:ue(h.allow),deny:ue(h.deny)}}catch{}}this.agents.set(e.path,i)}else this.skills.set(e.path,i)}async reloadFolderAgentContaining(t){let e=`${this.getSubfolder("agents")}/`,n=t.slice(e.length).split("/")[0];if(!n)return;let a=(0,x.normalizePath)(`${e}${n}`),i=(0,x.normalizePath)(`${a}/agent.md`);if(this.agents.delete(i),!(this.vault.getAbstractFileByPath(a)instanceof x.TFolder))return;let c=this.vault.getAbstractFileByPath(i);if(!(c instanceof x.TFile))return;let l=await this.loadFolderAgent(a,c);l&&this.agents.set(i,l)}isInsideAgentFolder(t){let e=`${this.getSubfolder("agents")}/`;return t.startsWith(e)?t.slice(e.length).includes("/"):!1}isInsideSkillFolder(t){let e=`${this.getSubfolder("skills")}/`;return t.startsWith(e)?t.slice(e.length).includes("/"):!1}async reloadFolderSkillContaining(t){let e=`${this.getSubfolder("skills")}/`,n=t.slice(e.length).split("/")[0];if(!n)return;let a=(0,x.normalizePath)(`${e}${n}`),i=(0,x.normalizePath)(`${a}/skill.md`);if(this.skills.delete(i),!(this.vault.getAbstractFileByPath(a)instanceof x.TFolder))return;let c=this.vault.getAbstractFileByPath(i);if(!(c instanceof x.TFile))return;let l=await this.loadFolderSkill(a,c);l&&this.skills.set(i,l)}async loadFolderSkills(){let t=this.vault.getAbstractFileByPath(this.getSubfolder("skills"));if(t instanceof x.TFolder)for(let e of t.children){if(!(e instanceof x.TFolder))continue;let s=(0,x.normalizePath)(`${e.path}/skill.md`),n=this.vault.getAbstractFileByPath(s);if(!(n instanceof x.TFile))continue;let a=await this.loadFolderSkill(e.path,n);a&&this.skills.set(s,a)}}async loadFolderSkill(t,e){let s=await this.vault.cachedRead(e),{frontmatter:n,body:a}=J(s),i=A(n.name);if(!i)return this.setIssue(e.path,"Folder skill skill.md requires string field `name`."),null;let o=async c=>{let l=(0,x.normalizePath)(`${t}/${c}`),h=this.vault.getAbstractFileByPath(l);if(!(h instanceof x.TFile))return"";let d=await this.vault.cachedRead(h);return J(d).body};return{filePath:e.path,name:i,description:A(n.description),tags:ue(n.tags),body:a,toolsBody:await o("tools.md"),referencesBody:await o("references.md"),examplesBody:await o("examples.md"),isFolder:!0}}async loadFolderAgents(){let t=this.vault.getAbstractFileByPath(this.getSubfolder("agents"));if(t instanceof x.TFolder)for(let e of t.children){if(!(e instanceof x.TFolder))continue;let s=(0,x.normalizePath)(`${e.path}/agent.md`),n=this.vault.getAbstractFileByPath(s);if(!(n instanceof x.TFile))continue;let a=await this.loadFolderAgent(e.path,n);a&&this.agents.set(s,a)}}async loadFolderAgent(t,e){let s=await this.vault.cachedRead(e),{frontmatter:n,body:a}=J(s),i=A(n.name);if(!i)return this.setIssue(e.path,"Folder agent agent.md requires string field `name`."),null;let o={},c=(0,x.normalizePath)(`${t}/config.md`),l=this.vault.getAbstractFileByPath(c);if(l instanceof x.TFile){let M=await this.vault.cachedRead(l);o=J(M).frontmatter}let h={allow:[],deny:[]},d=(0,x.normalizePath)(`${t}/permissions.json`),u=this.vault.getAbstractFileByPath(d);if(u instanceof x.TFile)try{let M=await this.vault.cachedRead(u),U=JSON.parse(M);h={allow:ue(U.allow),deny:ue(U.deny)}}catch{}if(h.allow.length===0&&h.deny.length===0){let M=ue(o.allowed_tools),U=ue(o.blocked_tools);(M.length>0||U.length>0)&&(h={allow:M,deny:U},this.warnedLegacyPerms.has(i)||(this.warnedLegacyPerms.add(i),console.warn(`Agent Fleet: "${i}" still uses legacy allowed_tools/blocked_tools in config.md. Permission rules now live in permissions.json. Open this agent in Edit and Save to migrate.`)))}let p="",m=(0,x.normalizePath)(`${t}/SKILLS.md`),f=this.vault.getAbstractFileByPath(m);if(f instanceof x.TFile){let M=await this.vault.cachedRead(f);p=J(M).body}let g="",y=(0,x.normalizePath)(`${t}/CONTEXT.md`),v=this.vault.getAbstractFileByPath(y);if(v instanceof x.TFile){let M=await this.vault.cachedRead(v);g=J(M).body}let k=!1,w="",S="",T=!0,_="",D="",O=(0,x.normalizePath)(`${t}/HEARTBEAT.md`),C=this.vault.getAbstractFileByPath(O);if(C instanceof x.TFile){let M=await this.vault.cachedRead(C),U=J(M);k=Be(U.frontmatter.enabled,!1),w=A(U.frontmatter.schedule)??"",T=Be(U.frontmatter.notify,!0),_=A(U.frontmatter.channel)??"",D=A(U.frontmatter.channel_target)??"",S=U.body}let E=A(n.model),P=A(o.model);E&&P&&E!==P&&(this.warnedFolderAgentModelConflict.has(i)||(this.warnedFolderAgentModelConflict.add(i),console.warn(`Agent Fleet: "${i}" has conflicting model fields \u2014 agent.md says "${E}", config.md says "${P}". config.md wins. Remove agent.md's model field or sync the values to silence this warning.`)));let N=P??E??this.settings.defaultModel;return{filePath:e.path,name:i,description:A(n.description),model:N,adapter:A(o.adapter)??"claude-code",permissionMode:A(o.permission_mode)??"bypassPermissions",effort:A(o.effort),maxRetries:Re(o.max_retries,1),skills:ue(n.skills),mcpServers:ue(n.mcp_servers),cwd:A(o.cwd)||A(n.cwd),enabled:Be(n.enabled,!0),timeout:Re(o.timeout,Re(n.timeout,300)),approvalRequired:ue(o.approval_required),memory:Be(o.memory,Be(n.memory,!1)),memoryMaxEntries:Re(o.memory_max_entries,100),memoryTokenBudget:Pr(o,n),reflection:Rr(o,n),autoCompactThreshold:Re(o.auto_compact_threshold??n.auto_compact_threshold,85),tags:ue(n.tags),avatar:A(n.avatar)??"",body:a,contextBody:g,skillsBody:p,env:this.parseEnvMap(o.env),permissionRules:h,isFolder:!0,heartbeatEnabled:k,heartbeatSchedule:w,heartbeatBody:S,heartbeatNotify:T,heartbeatChannel:_,heartbeatChannelTarget:D,wikiKeeper:this.parseWikiKeeperConfig(o.wiki_keeper??n.wiki_keeper),wikiReferences:this.parseWikiReferences(o.wiki_references??n.wiki_references)}}parseWikiReferences(t){if(!Array.isArray(t))return;let e=[];for(let s of t)if(typeof s=="string"&&s.trim())e.push({agent:s.trim()});else if(s&&typeof s=="object"){let n=s.agent;typeof n=="string"&&n.trim()&&e.push({agent:n.trim()})}return e.length>0?e:void 0}parseWikiKeeperConfig(t){if(!t||typeof t!="object")return;let e=t;return{scopeRoot:A(e.scope_root)??"",inboxPath:A(e.inbox_path)??"_sources/inbox",archivePath:A(e.archive_path)??"_sources/archive",failedPath:A(e.failed_path)??"_sources/failed",topicsRoot:A(e.topics_root)??"_topics",indexPath:A(e.index_path)??"index.md",logPath:A(e.log_path)??"log.md",watchedFolders:ue(e.watched_folders),excludePatterns:ue(e.exclude_patterns),watchedSince:A(e.watched_since)??"",fileSubstantiveAnswers:Be(e.file_substantive_answers,!0),obsidianUrlScheme:Be(e.obsidian_url_scheme,!0),maxTokensPerIngest:Re(e.max_tokens_per_ingest,6e4),maxTokensPerRefresh:Re(e.max_tokens_per_refresh,3e4),indexSplitThreshold:Re(e.index_split_threshold,100),dedupSimilarityThreshold:Re(e.dedup_similarity_threshold,.82),summaryStaleDays:Re(e.summary_stale_days,30),stateFile:A(e.state_file)??".wiki-keeper-state.json"}}removeFile(t){this.clearStoredFile(t)}getSnapshot(){return{agents:Array.from(this.agents.values()).sort((t,e)=>t.name.localeCompare(e.name)),skills:Array.from(this.skills.values()).sort((t,e)=>t.name.localeCompare(e.name)),tasks:Array.from(this.tasks.values()).sort((t,e)=>t.taskId.localeCompare(e.taskId)),channels:Array.from(this.channels.values()).sort((t,e)=>t.name.localeCompare(e.name)),mcpServers:Array.from(this.mcpServers.values()).sort((t,e)=>t.name.localeCompare(e.name)),validationIssues:Array.from(this.validationIssues.values()).flat()}}getAgentByName(t){return Array.from(this.agents.values()).find(e=>e.name===t)}getSkillByName(t){return Array.from(this.skills.values()).find(e=>e.name===t)}getTaskById(t){return Array.from(this.tasks.values()).find(e=>e.taskId===t)}getTasksForAgent(t){return Array.from(this.tasks.values()).filter(e=>e.agent===t)}getChannelByName(t){return Array.from(this.channels.values()).find(e=>e.name===t)}getChannelsForAgent(t){return Array.from(this.channels.values()).filter(e=>e.defaultAgent===t)}getMcpServers(){return Array.from(this.mcpServers.values()).sort((t,e)=>t.name.localeCompare(e.name))}getMcpServerByName(t){return Array.from(this.mcpServers.values()).find(e=>e.name===t)}getRunsRoot(){return this.getSubfolder("runs")}getMemoryPath(t){return(0,x.normalizePath)(`${this.getSubfolder("memory")}/${oe(t)}.md`)}getMemoryDir(t){return(0,x.normalizePath)(`${this.getSubfolder("memory")}/${oe(t)}`)}getWorkingMemoryPath(t){return(0,x.normalizePath)(`${this.getMemoryDir(t)}/working.md`)}getRawMemoryPath(t,e){let s=e.slice(0,10);return(0,x.normalizePath)(`${this.getMemoryDir(t)}/raw/${s}.md`)}async readWorkingMemory(t){let e=this.getWorkingMemoryPath(t),s=this.vault.getAbstractFileByPath(e);if(s instanceof x.TFile){let i=await this.vault.cachedRead(s);return gr(i,e,t)}let n=this.getMemoryPath(t),a=this.vault.getAbstractFileByPath(n);if(a instanceof x.TFile){let i=await this.vault.cachedRead(a),{body:o}=J(i);return Zn(o,e,t)}return null}async writeWorkingMemory(t,e){let s=this.getWorkingMemoryPath(t);await this.ensureFolder(this.getMemoryDir(t));let n=yr(e),a=this.vault.getAbstractFileByPath(s);a instanceof x.TFile?await this.vault.modify(a,n):await this.createFileIfMissing(s,n);let i=this.getMemoryPath(t);i!==s&&this.vault.getAbstractFileByPath(i)&&await this.trashFile(i)}async migrateLegacyMemory(t){let e=this.getWorkingMemoryPath(t);if(this.vault.getAbstractFileByPath(e))return;let s=this.getMemoryPath(t),n=this.vault.getAbstractFileByPath(s);if(n instanceof x.TFile&&!this.migratingMemory.has(t)){this.migratingMemory.add(t);try{let a=await this.vault.cachedRead(n),{body:i}=J(a),o=new Date().toISOString(),c=i.split(` -`).map(d=>d.replace(/^\s*[-*]\s+/,"").trim()).filter(d=>d.length>0&&!/^#{1,6}\s/.test(d)),l=[`- ${o} [migrated] Imported ${c.length} legacy memory entr${c.length===1?"y":"ies"} (pre-v2).`,...c.map(d=>`- ${o} [migrated] ${d}`)];await this.appendRawMemory(t,l,o);let h=Zn(i,e,t,o);await this.writeWorkingMemory(t,h)}finally{this.migratingMemory.delete(t)}}}async migrateAllLegacyMemory(){for(let t of this.getSnapshot().agents)if(t.memory)try{await this.migrateLegacyMemory(t.name)}catch(e){console.warn(`Agent Fleet: legacy memory migration failed for "${t.name}"`,e)}}getPendingDir(t){return(0,x.normalizePath)(`${this.getMemoryDir(t)}/pending`)}getPendingDirAbsolutePath(t){let e=this.vault.adapter;return e instanceof x.FileSystemAdapter?(0,Mr.join)(e.getBasePath(),this.getPendingDir(t)):null}async ensureMemoryDir(t){await this.ensureFolder(this.getMemoryDir(t))}async readAndClearPending(t){let e=this.vault.adapter,s=this.getPendingDir(t),n;try{if(!await e.exists(s))return[];n=(await e.list(s)).files}catch{return[]}let a=[];for(let i of n)if(i.endsWith(".json"))try{let o=await e.read(i);await e.remove(i);for(let c of o.split(` -`))c.trim().length>0&&a.push(c)}catch{}return a}async readRecentRaw(t,e=2){let s=(0,x.normalizePath)(`${this.getMemoryDir(t)}/raw`),n=this.vault.getAbstractFileByPath(s);if(!(n instanceof x.TFolder))return"";let a=n.children.filter(o=>o instanceof x.TFile&&o.extension==="md").sort((o,c)=>c.name.localeCompare(o.name)).slice(0,e),i=[];for(let o of a.reverse())i.push(`### ${o.basename} -${await this.vault.cachedRead(o)}`);return i.join(` - -`)}getCandidatesPath(t){return(0,x.normalizePath)(`${this.getMemoryDir(t)}/candidates.json`)}async readCandidates(t){let e=this.getCandidatesPath(t),s=this.vault.getAbstractFileByPath(e);if(!(s instanceof x.TFile))return[];try{let n=JSON.parse(await this.vault.cachedRead(s));return Array.isArray(n)?n:[]}catch{return[]}}async writeCandidates(t,e){let s=this.getCandidatesPath(t);await this.ensureFolder(this.getMemoryDir(t));let n=JSON.stringify(e,null,2),a=this.vault.getAbstractFileByPath(s);a instanceof x.TFile?await this.vault.modify(a,n):await this.createFileIfMissing(s,n)}getProposalsDir(){return(0,x.normalizePath)(`${this.getFleetRoot()}/proposals`)}async listProposals(){let t=this.vault.getAbstractFileByPath(this.getProposalsDir());if(!(t instanceof x.TFolder))return[];let e=[];for(let s of t.children){if(!(s instanceof x.TFile)||s.extension!=="md")continue;let n=await this.readProposal(s.basename);n&&e.push(n)}return e.sort((s,n)=>n.created.localeCompare(s.created)),e}async readProposal(t){let e=(0,x.normalizePath)(`${this.getProposalsDir()}/${t}.md`),s=this.vault.getAbstractFileByPath(e);if(!(s instanceof x.TFile))return null;let{frontmatter:n,body:a}=J(await this.vault.cachedRead(s)),i=n.type==="skill_modify"?"skill_modify":"skill_create",o=n.status==="accepted"||n.status==="rejected"?n.status:"pending";return{id:t,type:i,agent:A(n.agent)??"",status:o,created:A(n.created)??"",targetSkill:A(n.target_skill),candidate:A(n.candidate),evidence:ue(n.evidence),rationale:A(n.rationale)??"",body:a}}async writeProposal(t){await this.ensureFolder(this.getProposalsDir());let e=(0,x.normalizePath)(`${this.getProposalsDir()}/${t.id}.md`),s={id:t.id,type:t.type,agent:t.agent,status:t.status,created:t.created,target_skill:t.targetSkill||void 0,candidate:t.candidate||void 0,evidence:t.evidence.length?t.evidence:void 0,rationale:t.rationale||void 0},n=H(s,t.body||""),a=this.vault.getAbstractFileByPath(e);a instanceof x.TFile?await this.vault.modify(a,n):await this.createFileIfMissing(e,n)}async setProposalStatus(t,e){let s=await this.readProposal(t);s&&(s.status=e,await this.writeProposal(s))}async applyProposal(t){if(t.type==="skill_create"){let e=t.targetSkill||`learned-${oe(t.rationale).slice(0,24)||"skill"}`,s=await this.getAvailablePath(this.getSubfolder("skills"),oe(e)),a={name:s.split("/").pop()?.replace(/\.md$/,"")||oe(e),description:t.rationale||`Auto-proposed from recurring pattern for ${t.agent}.`,tags:["proposed"]};return await this.vault.create(s,H(a,t.body||"Skill instructions go here.")),s}if(t.targetSkill){let e=this.getSkillByName(t.targetSkill);if(e){let s=this.vault.getAbstractFileByPath(e.filePath);if(s instanceof x.TFile){let n=await this.vault.cachedRead(s),a=` - -## Proposed update (${new Date().toISOString().slice(0,10)}) -${t.body}`;return await this.vault.modify(s,`${n.trimEnd()}${a} -`),e.filePath}}}return null}async deleteProposal(t){await this.trashFile((0,x.normalizePath)(`${this.getProposalsDir()}/${t}.md`))}async appendRawMemory(t,e,s){if(e.length===0)return;let n=this.getRawMemoryPath(t,s);await this.ensureFolder(n.replace(/\/[^/]+$/,""));let a=e.map(o=>o.trimEnd()).join(` -`),i=this.vault.getAbstractFileByPath(n);if(i instanceof x.TFile){let o=await this.vault.cachedRead(i);await this.vault.modify(i,`${o.trimEnd()} -${a} -`)}else await this.createFileIfMissing(n,`${a} -`)}getConversationsDir(t){if(t.isFolder){let s=t.filePath.replace(/\/agent\.md$/,"");return(0,x.normalizePath)(`${s}/conversations`)}let e=this.getMemoryPath(t.name).replace(/\/[^/]+$/,"");return(0,x.normalizePath)(`${e}/${t.name}-conversations`)}getConversationPath(t,e){let s=this.getConversationsDir(t),n=oe(e)||"conversation";return(0,x.normalizePath)(`${s}/${n}.json`)}async listConversations(t){let e=[],s=this.getConversationsDir(t),n=this.vault.getAbstractFileByPath(s);if(n instanceof x.TFolder)for(let a of n.children){if(!(a instanceof x.TFile)||a.extension!=="json")continue;let i=a.basename,o=await this.readConversationMeta(a,i);o&&e.push(o)}return e.sort((a,i)=>i.lastActive.localeCompare(a.lastActive)),e}async readConversationMeta(t,e){try{let s=await this.vault.cachedRead(t),n=JSON.parse(s);return{id:e,name:n.name?.trim()||"Untitled",lastActive:n.lastActive??new Date(t.stat.mtime).toISOString(),messageCount:Array.isArray(n.messages)?n.messages.length:0}}catch{return null}}async createConversation(t,e,s){let n=this.getConversationPath(t,e);if(this.vault.getAbstractFileByPath(n)instanceof x.TFile)return;let i=n.replace(/\/[^/]+$/,"");await this.ensureFolder(i);let o=new Date().toISOString(),c={sessionId:null,messages:[],lastActive:o,createdAt:o,name:s.trim()};await this.vault.create(n,JSON.stringify(c,null,2))}async renameConversation(t,e,s){let n=this.getConversationPath(t,e),a=this.vault.getAbstractFileByPath(n);if(!(a instanceof x.TFile))return;let i=await this.vault.cachedRead(a),o;try{o=JSON.parse(i)}catch{return}o.name=s.trim(),await this.vault.modify(a,JSON.stringify(o,null,2))}async deleteConversation(t,e){let s=this.getConversationPath(t,e),n=this.vault.getAbstractFileByPath(s);n instanceof x.TFile&&await this.app.fileManager.trashFile(n);let a=s.replace(/\.json$/,".threads"),i=this.vault.getAbstractFileByPath(a);i instanceof x.TFolder&&await this.app.fileManager.trashFile(i);let o=this.getConversationsDir(t),c=this.vault.getAbstractFileByPath(o);c instanceof x.TFolder&&c.children.length===0&&await this.app.fileManager.trashFile(c)}async getMemory(t){let e=await this.readWorkingMemory(t);return e?{filePath:e.filePath,agent:e.agent,lastUpdated:e.lastUpdated,body:Ke(e.sections)}:null}async appendMemory(t,e){if(e.length===0)return;let s=this.getMemoryPath(t),n=this.vault.getAbstractFileByPath(s),a=new Date().toISOString(),i=e.map(o=>`- ${o.trim()}`).join(` -`);if(n instanceof x.TFile){let c=`${(await this.getMemory(t))?.body.trim()||"## Learned Context"} - -${i}`.trim();await this.vault.modify(n,H({agent:t,last_updated:a},c));return}await this.createFileIfMissing(s,H({agent:t,last_updated:a},`## Learned Context - -${i}`))}async listRecentRuns(t=50){let e=this.vault.getAbstractFileByPath(this.getRunsRoot());if(!(e instanceof x.TFolder))return[];let s=[];this.collectMarkdownChildren(e,s),s.sort((i,o)=>o.path.localeCompare(i.path));let n=s.slice(0,t),a=[];for(let i of n){let o=await this.readRunLog(i);o&&a.push(o)}return a.sort((i,o)=>o.started.localeCompare(i.started))}async listRunsSince(t){let e=this.vault.getAbstractFileByPath(this.getRunsRoot());if(!(e instanceof x.TFolder))return[];let s=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`,n=[];for(let i of e.children)i instanceof x.TFolder&&(i.nameo.started.localeCompare(i.started))}usageLedgerPath(t){return(0,x.normalizePath)(`${this.getSubfolder("usage")}/${t.slice(0,10)}.jsonl`)}async appendUsage(t){await this.ensureFolder(this.getSubfolder("usage"));let e=this.usageLedgerPath(t.ts),s=`${JSON.stringify(t)} -`,n=this.vault.adapter;await n.exists(e)?await n.append(e,s):await n.write(e,s)}async readUsageSince(t){let e=this.getSubfolder("usage"),s=this.vault.adapter;if(!await s.exists(e))return[];let n=`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`,a=[],i=await s.list(e);for(let o of i.files){if(!o.endsWith(".jsonl")||(o.split("/").pop()??"").replace(/\.jsonl$/,"")0)for(let{path:c,records:l}of a){if(l.length===0)continue;let h=l.map(d=>JSON.stringify(d)).join(` -`)+` -`;await e.write(c,h)}return await e.write(s,`migrated ${i.length} rows; corrected ${o} -`),{files:a.length,rows:i.length,changed:o}}catch(n){return console.error("Agent Fleet: usage-ledger cost migration failed (ledger left untouched)",n),null}}async readRunLog(t){let e=await this.vault.cachedRead(t),{frontmatter:s,body:n}=J(e),a=n.match(/## Prompt\n([\s\S]*?)(?:\n## Result\n|\n## Output\n|$)/),i=n.match(/## Result\n([\s\S]*?)(?:\n## Output\n|$)/),o=n.match(/## Output\n([\s\S]*?)(?:\n## Tools Used\n|$)/),c=n.match(/## Tools Used\n([\s\S]*?)(?:\n## STDERR\n|$)/);return{filePath:t.path,runId:A(s.run_id)??t.basename,agent:A(s.agent)??"unknown",task:A(s.task)??"unknown",status:A(s.status)??"failure",started:A(s.started)??new Date(t.stat.ctime).toISOString(),completed:A(s.completed),durationSeconds:Re(s.duration_seconds,0),tokensUsed:typeof s.tokens_used=="number"?s.tokens_used:void 0,costUsd:typeof s.cost_usd=="number"?s.cost_usd:void 0,model:A(s.model)??it.defaultModel,modelSource:(()=>{let l=A(s.model_source);if(l==="task"||l==="agent"||l==="settings"||l==="cli-default")return l})(),concreteModel:A(s.resolved_concrete_model),exitCode:typeof s.exit_code=="number"?s.exit_code:null,tags:ue(s.tags),prompt:a?.[1]?.trim()??"",output:o?.[1]?.trim()??"",finalResult:i?.[1]?.trim()||void 0,toolsUsed:c?.[1]?ye(c[1]).map(l=>l.replace(/^- /,"").trim()).filter(Boolean):[],approvals:this.parseApprovals(s.approvals)}}async writeRunLog(t){let e=new Date(t.started),s=(0,x.normalizePath)(`${this.getRunsRoot()}/${e.toISOString().slice(0,10)}`);await this.ensureFolder(s);let n=`${e.toISOString().slice(11,19).replace(/:/g,"")}-${oe(t.agent)}-${oe(t.task)}.md`,a=(0,x.normalizePath)(`${s}/${n}`),i=H({run_id:t.runId,agent:t.agent,task:t.task,status:t.status,started:t.started,completed:t.completed,duration_seconds:t.durationSeconds,tokens_used:t.tokensUsed,cost_usd:t.costUsd,model:t.model,model_source:t.modelSource,resolved_concrete_model:t.concreteModel,exit_code:t.exitCode,tags:t.tags,approvals:t.approvals},["## Prompt","",t.prompt.trim(),"",...t.finalResult&&t.finalResult.trim()?["## Result","",t.finalResult.trim(),""]:[],"## Output","",t.output.trim()||"(no output)","","## Tools Used","",...t.toolsUsed.length>0?t.toolsUsed.map(c=>`- ${c}`):["- none"],...t.stderr?["","## STDERR","",t.stderr.trim()]:[]].join(` -`)),o=this.vault.getAbstractFileByPath(a);return o instanceof x.TFile?await this.vault.modify(o,i):await this.vault.create(a,i),a}async updateTaskRunMetadata(t,e){let s=this.vault.getAbstractFileByPath(t.filePath);if(!(s instanceof x.TFile))return;let n=await this.vault.cachedRead(s),{frontmatter:a,body:i}=J(n),o={...a,last_run:e.lastRun??t.lastRun,next_run:e.nextRun??t.nextRun,run_count:e.runCount??t.runCount};await this.vault.modify(s,H(o,i)),await this.loadFile(s)}async setApprovalDecision(t,e,s){let n=this.vault.getAbstractFileByPath(t);if(!(n instanceof x.TFile))return;let a=await this.vault.cachedRead(n),{frontmatter:i,body:o}=J(a),c=(this.parseApprovals(i.approvals)??[]).map(l=>l.tool===e?{...l,status:s,resolvedAt:new Date().toISOString()}:l);await this.vault.modify(n,H({...i,approvals:c},o))}async createAgentTemplate(t){let e=await this.getAvailablePath(this.getSubfolder("agents"),oe(t)),s=`--- -name: ${oe(t)} +`}function z(a){return a.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"")}function At(a,e){return a.length<=e?a:`${a.slice(0,e-1)}\u2026`}var xn=class{constructor(e,t){this.app=e;this.getLegacyMemoryPath=t;this.vault=e.vault}vault;getConversationsDir(e){if(e.isFolder){let s=e.filePath.replace(/\/agent\.md$/,"");return(0,nt.normalizePath)(`${s}/conversations`)}let t=this.getLegacyMemoryPath(e.name).replace(/\/[^/]+$/,"");return(0,nt.normalizePath)(`${t}/${e.name}-conversations`)}getConversationPath(e,t){let s=this.getConversationsDir(e),n=z(t)||"conversation";return(0,nt.normalizePath)(`${s}/${n}.json`)}async listConversations(e){let t=[],s=this.getConversationsDir(e),n=this.vault.getAbstractFileByPath(s);if(n instanceof nt.TFolder)for(let r of n.children){if(!(r instanceof nt.TFile)||r.extension!=="json")continue;let i=r.basename,o=await this.readConversationMeta(r,i);o&&t.push(o)}return t.sort((r,i)=>i.lastActive.localeCompare(r.lastActive)),t}async readConversationMeta(e,t){try{let s=await this.vault.cachedRead(e),n=JSON.parse(s);return{id:t,name:n.name?.trim()||"Untitled",lastActive:n.lastActive??new Date(e.stat.mtime).toISOString(),messageCount:Array.isArray(n.messages)?n.messages.length:0}}catch(s){return console.warn(`Agent Fleet: skipping unreadable conversation file ${e.path}`,s),null}}async createConversation(e,t,s){let n=this.getConversationPath(e,t);if(this.vault.getAbstractFileByPath(n)instanceof nt.TFile)return;let i=n.replace(/\/[^/]+$/,"");await fe(this.vault,i);let o=new Date().toISOString(),l={sessionId:null,messages:[],lastActive:o,createdAt:o,name:s.trim()};await this.vault.create(n,JSON.stringify(l,null,2))}async renameConversation(e,t,s){let n=this.getConversationPath(e,t),r=this.vault.getAbstractFileByPath(n);if(!(r instanceof nt.TFile))return;let i=await this.vault.cachedRead(r),o;try{o=JSON.parse(i)}catch(l){console.warn(`Agent Fleet: cannot rename conversation \u2014 invalid JSON in ${n}`,l);return}o.name=s.trim(),await this.vault.modify(r,JSON.stringify(o,null,2))}async deleteConversation(e,t){let s=this.getConversationPath(e,t),n=this.vault.getAbstractFileByPath(s);n instanceof nt.TFile&&await this.app.fileManager.trashFile(n);let r=s.replace(/\.json$/,".threads"),i=this.vault.getAbstractFileByPath(r);i instanceof nt.TFolder&&await this.app.fileManager.trashFile(i);let o=this.getConversationsDir(e),l=this.vault.getAbstractFileByPath(o);l instanceof nt.TFolder&&l.children.length===0&&await this.app.fileManager.trashFile(l)}};var N=require("obsidian");var ze=require("obsidian");var ei=!1;function ti(a,e={}){let t=a.memory_token_budget??e.memory_token_budget;return t!==void 0?Pe(t,bn):((a.memory_max_entries??e.memory_max_entries)!==void 0&&!ei&&(ei=!0,console.info(`Agent Fleet: \`memory_max_entries\` is deprecated and no longer enforced; memory is now bounded by \`memory_token_budget\` (default ${bn}). Set that field to tune memory size.`)),bn)}function si(a,e={}){let t=s=>a[s]??e[s];return{enabled:Ie(t("reflection_enabled"),!1),schedule:C(t("reflection_schedule"))??Qr,recurrenceThreshold:Pe(t("reflection_recurrence_threshold"),Zr),proposeSkills:Ie(t("reflection_propose_skills"),!1),model:C(t("reflection_model"))}}function Os(a){return(0,ze.normalizePath)(a.replace(/\.md$/,".permissions.json"))}function ni(a){if(!Tt(a))return{};let e={};for(let[t,s]of Object.entries(a))typeof s=="string"?e[t]=s:(typeof s=="number"||typeof s=="boolean")&&(e[t]=String(s));return e}function Wc(a){if(!Array.isArray(a))return;let e=[];for(let t of a)if(typeof t=="string"&&t.trim())e.push({agent:t.trim()});else if(t&&typeof t=="object"){let s=t.agent;typeof s=="string"&&s.trim()&&e.push({agent:s.trim()})}return e.length>0?e:void 0}function qc(a){if(!a||typeof a!="object")return;let e=a;return{scopeRoot:C(e.scope_root)??"",inboxPath:C(e.inbox_path)??"_sources/inbox",archivePath:C(e.archive_path)??"_sources/archive",failedPath:C(e.failed_path)??"_sources/failed",topicsRoot:C(e.topics_root)??"_topics",indexPath:C(e.index_path)??"index.md",logPath:C(e.log_path)??"log.md",watchedFolders:Q(e.watched_folders),excludePatterns:Q(e.exclude_patterns),watchedSince:C(e.watched_since)??"",fileSubstantiveAnswers:Ie(e.file_substantive_answers,!0),obsidianUrlScheme:Ie(e.obsidian_url_scheme,!0),maxTokensPerIngest:Pe(e.max_tokens_per_ingest,6e4),maxTokensPerRefresh:Pe(e.max_tokens_per_refresh,3e4),indexSplitThreshold:Pe(e.index_split_threshold,100),dedupSimilarityThreshold:Pe(e.dedup_similarity_threshold,.82),summaryStaleDays:Pe(e.summary_stale_days,30),stateFile:C(e.state_file)??".wiki-keeper-state.json"}}function ai(a,e,t){let{frontmatter:s,body:n}=W(t);if(!Tt(s))return a.reportIssue(e,"Invalid frontmatter."),null;let r=C(s.name),i=C(s.model)??a.settings.defaultModel;if(!r||!i)return a.reportIssue(e,"Agent requires string field `name` and a valid model or default model setting."),null;let o=Q(s.allowed_tools),l=Q(s.blocked_tools);return(o.length>0||l.length>0)&&!a.warnedLegacyPerms.has(r)&&(a.warnedLegacyPerms.add(r),console.warn(`Agent Fleet: "${r}" still uses legacy allowed_tools/blocked_tools in its frontmatter. Permission rules now live in .permissions.json beside the .md. Open Edit and Save to migrate.`)),{filePath:e,name:r,description:C(s.description),model:i,adapter:C(s.adapter)??"claude-code",permissionMode:C(s.permission_mode)??"bypassPermissions",effort:C(s.effort),maxRetries:Pe(s.max_retries,1),skills:Q(s.skills),mcpServers:Q(s.mcp_servers),cwd:C(s.cwd),enabled:Ie(s.enabled,!0),timeout:Pe(s.timeout,300),approvalRequired:Q(s.approval_required),memory:Ie(s.memory,!1),memoryMaxEntries:Pe(s.memory_max_entries,100),memoryTokenBudget:ti(s),reflection:si(s),autoCompactThreshold:Pe(s.auto_compact_threshold,85),tags:Q(s.tags),avatar:C(s.avatar)??"",body:n,contextBody:"",skillsBody:"",env:ni(s.env),permissionRules:{allow:o,deny:l},isFolder:!1,heartbeatEnabled:!1,heartbeatSchedule:"",heartbeatBody:"",heartbeatNotify:!0,heartbeatChannel:"",heartbeatChannelTarget:""}}function ri(a,e,t){let{frontmatter:s,body:n}=W(t),r=C(s.name);return r?{filePath:e,name:r,description:C(s.description),tags:Q(s.tags),body:n,toolsBody:"",referencesBody:"",examplesBody:"",isFolder:!1}:(a.reportIssue(e,"Skill requires string field `name`."),null)}function ii(a,e,t){let{frontmatter:s,body:n}=W(t),r=C(s.task_id),i=C(s.agent),o=C(s.type);if(!r||!i||!o)return a.reportIssue(e,"Task requires `task_id`, `agent`, and `type`."),null;if(o==="recurring"&&!C(s.schedule))return a.reportIssue(e,"Recurring task requires `schedule`."),null;if(o==="once"&&!C(s.run_at))return a.reportIssue(e,"One-time task requires `run_at`."),null;let l=C(s.priority),d=l&&["low","medium","high","critical"].includes(l)?l:"medium";return{filePath:e,taskId:r,agent:i,schedule:C(s.schedule),runAt:C(s.run_at),type:o,priority:d,enabled:Ie(s.enabled,!0),created:C(s.created)??new Date().toISOString(),lastRun:C(s.last_run),nextRun:C(s.next_run),runCount:Pe(s.run_count,0),catchUp:Ie(s.catch_up,a.settings.catchUpMissedTasks),effort:C(s.effort),model:C(s.model),channel:C(s.channel),channelTarget:C(s.channel_target),tags:Q(s.tags),body:n}}function oi(a,e,t){let{frontmatter:s,body:n}=W(t),r=C(s.name);if(!r)return a.reportIssue(e,"Channel requires string field `name`."),null;let i=C(s.type),o=["slack","telegram","discord"];if(!i||!o.includes(i))return a.reportIssue(e,`Channel \`${r}\` requires \`type\` to be one of: ${o.join(", ")}.`),null;let l=i,c=C(s.default_agent)??C(s.agent);if(!c)return a.reportIssue(e,`Channel \`${r}\` requires \`default_agent\` (or \`agent\`).`),null;let d=Q(s.allowed_agents),u=C(s.credential_ref);if(!u)return a.reportIssue(e,`Channel \`${r}\` requires \`credential_ref\` pointing at a configured credential.`),null;let h=Tt(s.transport)?s.transport:{};return{filePath:e,name:r,type:l,defaultAgent:c,allowedAgents:d,enabled:Ie(s.enabled,!0),credentialRef:u,allowedUsers:Q(s.allowed_users),perUserSessions:Ie(s.per_user_sessions,!0),channelContext:C(s.channel_context)??"",transport:h,tags:Q(s.tags),body:n}}async function Ba(a,e,t){let s=await a.vault.cachedRead(t),{frontmatter:n,body:r}=W(s),i=C(n.name);if(!i)return a.reportIssue(t.path,"Folder skill skill.md requires string field `name`."),null;let o=async l=>{let c=(0,ze.normalizePath)(`${e}/${l}`),d=a.vault.getAbstractFileByPath(c);if(!(d instanceof ze.TFile))return"";let u=await a.vault.cachedRead(d);return W(u).body};return{filePath:t.path,name:i,description:C(n.description),tags:Q(n.tags),body:r,toolsBody:await o("tools.md"),referencesBody:await o("references.md"),examplesBody:await o("examples.md"),isFolder:!0}}async function Ua(a,e,t){let s=await a.vault.cachedRead(t),{frontmatter:n,body:r}=W(s),i=C(n.name);if(!i)return a.reportIssue(t.path,"Folder agent agent.md requires string field `name`."),null;let o={},l=(0,ze.normalizePath)(`${e}/config.md`),c=a.vault.getAbstractFileByPath(l);if(c instanceof ze.TFile){let F=await a.vault.cachedRead(c);o=W(F).frontmatter}let d={allow:[],deny:[]},u=(0,ze.normalizePath)(`${e}/permissions.json`);a.clearIssue(u);let h=a.vault.getAbstractFileByPath(u);if(h instanceof ze.TFile)try{let F=await a.vault.cachedRead(h),B=JSON.parse(F);d={allow:Q(B.allow),deny:Q(B.deny)}}catch(F){console.error(`Agent Fleet: invalid JSON in ${u} \u2014 agent "${i}" is running with EMPTY permission rules`,F),a.reportIssue(u,`Invalid JSON in permissions.json for agent \`${i}\` \u2014 permission rules are NOT applied. Fix or delete the file.`)}if(d.allow.length===0&&d.deny.length===0){let F=Q(o.allowed_tools),B=Q(o.blocked_tools);(F.length>0||B.length>0)&&(d={allow:F,deny:B},a.warnedLegacyPerms.has(i)||(a.warnedLegacyPerms.add(i),console.warn(`Agent Fleet: "${i}" still uses legacy allowed_tools/blocked_tools in config.md. Permission rules now live in permissions.json. Open this agent in Edit and Save to migrate.`)))}let p="",m=(0,ze.normalizePath)(`${e}/SKILLS.md`),f=a.vault.getAbstractFileByPath(m);if(f instanceof ze.TFile){let F=await a.vault.cachedRead(f);p=W(F).body}let g="",w=(0,ze.normalizePath)(`${e}/CONTEXT.md`),y=a.vault.getAbstractFileByPath(w);if(y instanceof ze.TFile){let F=await a.vault.cachedRead(y);g=W(F).body}let v=!1,k="",b="",P=!0,A="",M="",E=(0,ze.normalizePath)(`${e}/HEARTBEAT.md`),x=a.vault.getAbstractFileByPath(E);if(x instanceof ze.TFile){let F=await a.vault.cachedRead(x),B=W(F);v=Ie(B.frontmatter.enabled,!1),k=C(B.frontmatter.schedule)??"",P=Ie(B.frontmatter.notify,!0),A=C(B.frontmatter.channel)??"",M=C(B.frontmatter.channel_target)??"",b=B.body}let _=C(n.model),R=C(o.model);_&&R&&_!==R&&(a.warnedFolderAgentModelConflict.has(i)||(a.warnedFolderAgentModelConflict.add(i),console.warn(`Agent Fleet: "${i}" has conflicting model fields \u2014 agent.md says "${_}", config.md says "${R}". config.md wins. Remove agent.md's model field or sync the values to silence this warning.`)));let O=R??_??a.settings.defaultModel;return{filePath:t.path,name:i,description:C(n.description),model:O,adapter:C(o.adapter)??"claude-code",permissionMode:C(o.permission_mode)??"bypassPermissions",effort:C(o.effort),maxRetries:Pe(o.max_retries,1),skills:Q(n.skills),mcpServers:Q(n.mcp_servers),cwd:C(o.cwd)||C(n.cwd),enabled:Ie(n.enabled,!0),timeout:Pe(o.timeout,Pe(n.timeout,300)),approvalRequired:Q(o.approval_required),memory:Ie(o.memory,Ie(n.memory,!1)),memoryMaxEntries:Pe(o.memory_max_entries,100),memoryTokenBudget:ti(o,n),reflection:si(o,n),autoCompactThreshold:Pe(o.auto_compact_threshold??n.auto_compact_threshold,85),tags:Q(n.tags),avatar:C(n.avatar)??"",body:r,contextBody:g,skillsBody:p,env:ni(o.env),permissionRules:d,isFolder:!0,heartbeatEnabled:v,heartbeatSchedule:k,heartbeatBody:b,heartbeatNotify:P,heartbeatChannel:A,heartbeatChannelTarget:M,wikiKeeper:qc(o.wiki_keeper??n.wiki_keeper),wikiReferences:Wc(o.wiki_references??n.wiki_references)}}var Sn=class{constructor(e,t){this.app=e;this.deps=t;this.vault=e.vault}vault;get store(){return this.deps.store}async updateTaskRunMetadata(e,t){let s=this.vault.getAbstractFileByPath(e.filePath);if(!(s instanceof N.TFile))return;let n=await this.vault.cachedRead(s),{frontmatter:r,body:i}=W(n),o={...r,last_run:t.lastRun??e.lastRun,next_run:t.nextRun??e.nextRun,run_count:t.runCount??e.runCount};await this.vault.modify(s,U(o,i)),await this.store.loadFile(s)}async createAgentTemplate(e){let t=await _t(this.vault,this.deps.getSubfolder("agents"),z(e)),s=`--- +name: ${z(e)} description: enabled: true skills: [] @@ -11680,37 +11644,74 @@ tags: [] --- Agent instructions go here. -`;return await this.vault.create(e,s)}async createAgentFolder(t){let e=oe(t.name),s=(0,x.normalizePath)(`${this.getSubfolder("agents")}/${e}`);await this.ensureFolder(s);let n={name:t.name,description:t.description||void 0,avatar:t.avatar||void 0,enabled:t.enabled??!0,tags:t.tags,skills:t.skills,mcp_servers:t.mcpServers?.length?t.mcpServers:void 0};t.model&&t.model!=="default"&&(n.model=t.model);let a=(0,x.normalizePath)(`${s}/agent.md`);await this.vault.create(a,H(n,t.systemPrompt||""));let i={model:t.model||"default",adapter:t.adapter||"claude-code",timeout:t.timeout,max_retries:1,cwd:t.cwd||"",permission_mode:t.permissionMode||"bypassPermissions",effort:t.effort||void 0,approval_required:t.approvalRequired,memory:t.memory,memory_max_entries:t.memoryMaxEntries};typeof t.autoCompactThreshold=="number"&&(i.auto_compact_threshold=t.autoCompactThreshold),t.wikiReferences&&t.wikiReferences.length>0&&(i.wiki_references=t.wikiReferences.map(d=>({agent:d})));let o=(0,x.normalizePath)(`${s}/config.md`);await this.vault.create(o,H(i,""));let c=(0,x.normalizePath)(`${s}/SKILLS.md`);await this.vault.create(c,H({},t.skillsBody||""));let l=(0,x.normalizePath)(`${s}/CONTEXT.md`);await this.vault.create(l,H({},t.contextBody||""));let h=t.permissionRules;if(h&&(h.allow.length>0||h.deny.length>0)){let d=(0,x.normalizePath)(`${s}/permissions.json`);await this.vault.create(d,JSON.stringify(h,null,2)+` -`)}return a}async createSkillTemplate(t){let e=await this.getAvailablePath(this.getSubfolder("skills"),oe(t)),s=`--- -name: ${oe(t)} +`,n=await this.vault.create(t,s);return await this.store.loadFile(n),n}async createAgentFolder(e){let t=z(e.name),s=(0,N.normalizePath)(`${this.deps.getSubfolder("agents")}/${t}`);await fe(this.vault,s);let n={name:e.name,description:e.description||void 0,avatar:e.avatar||void 0,enabled:e.enabled??!0,tags:e.tags,skills:e.skills,mcp_servers:e.mcpServers?.length?e.mcpServers:void 0};e.model&&e.model!=="default"&&(n.model=e.model);let r=(0,N.normalizePath)(`${s}/agent.md`);await this.vault.create(r,U(n,e.systemPrompt||""));let i={model:e.model||"default",adapter:e.adapter||"claude-code",timeout:e.timeout,max_retries:1,cwd:e.cwd||"",permission_mode:e.permissionMode||"bypassPermissions",effort:e.effort||void 0,approval_required:e.approvalRequired,memory:e.memory,memory_max_entries:e.memoryMaxEntries};typeof e.autoCompactThreshold=="number"&&(i.auto_compact_threshold=e.autoCompactThreshold),e.wikiReferences&&e.wikiReferences.length>0&&(i.wiki_references=e.wikiReferences.map(u=>({agent:u})));let o=(0,N.normalizePath)(`${s}/config.md`);await this.vault.create(o,U(i,""));let l=(0,N.normalizePath)(`${s}/SKILLS.md`);await this.vault.create(l,U({},e.skillsBody||""));let c=(0,N.normalizePath)(`${s}/CONTEXT.md`);await this.vault.create(c,U({},e.contextBody||""));let d=e.permissionRules;if(d&&(d.allow.length>0||d.deny.length>0)){let u=(0,N.normalizePath)(`${s}/permissions.json`);await this.vault.create(u,JSON.stringify(d,null,2)+` +`)}return await this.store.loadFile(r),r}async createSkillTemplate(e){let t=await _t(this.vault,this.deps.getSubfolder("skills"),z(e)),s=`--- +name: ${z(e)} description: tags: [] --- Skill instructions go here. -`;return await this.vault.create(e,s)}async createSkillFolder(t){let e=(0,x.normalizePath)(`${this.getSubfolder("skills")}/${oe(t.name)}`);await this.ensureFolder(e);let s={name:t.name,description:t.description||void 0,tags:t.tags.length>0?t.tags:void 0},n=(0,x.normalizePath)(`${e}/skill.md`);if(await this.createFileIfMissing(n,H(s,t.body||"Skill instructions go here.")),t.toolsBody){let a=(0,x.normalizePath)(`${e}/tools.md`);await this.createFileIfMissing(a,`# Tools +`,n=await this.vault.create(t,s);return await this.store.loadFile(n),n}async createSkillFolder(e){let t=(0,N.normalizePath)(`${this.deps.getSubfolder("skills")}/${z(e.name)}`);await fe(this.vault,t);let s={name:e.name,description:e.description||void 0,tags:e.tags.length>0?e.tags:void 0},n=(0,N.normalizePath)(`${t}/skill.md`);if(await We(this.vault,n,U(s,e.body||"Skill instructions go here.")),e.toolsBody){let r=(0,N.normalizePath)(`${t}/tools.md`);await We(this.vault,r,`# Tools -${t.toolsBody}`)}if(t.referencesBody){let a=(0,x.normalizePath)(`${e}/references.md`);await this.createFileIfMissing(a,`# References +${e.toolsBody}`)}if(e.referencesBody){let r=(0,N.normalizePath)(`${t}/references.md`);await We(this.vault,r,`# References -${t.referencesBody}`)}if(t.examplesBody){let a=(0,x.normalizePath)(`${e}/examples.md`);await this.createFileIfMissing(a,`# Examples +${e.referencesBody}`)}if(e.examplesBody){let r=(0,N.normalizePath)(`${t}/examples.md`);await We(this.vault,r,`# Examples -${t.examplesBody}`)}}async updateAgent(t,e){let s=this.getAgentByName(t);if(s)if(s.isFolder){let n=(0,x.normalizePath)(s.filePath.replace(/\/agent\.md$/,"")),a=this.vault.getAbstractFileByPath(s.filePath);if(a instanceof x.TFile){let c=await this.vault.cachedRead(a),{frontmatter:l,body:h}=J(c);e.description!==void 0&&(l.description=e.description||void 0),e.avatar!==void 0&&(l.avatar=e.avatar||void 0),e.tags!==void 0&&(l.tags=e.tags),e.skills!==void 0&&(l.skills=e.skills),e.mcpServers!==void 0&&(l.mcp_servers=e.mcpServers.length>0?e.mcpServers:void 0),e.enabled!==void 0&&(l.enabled=e.enabled),e.model!==void 0&&e.model!=="default"&&(l.model=e.model);let d=e.systemPrompt!==void 0?e.systemPrompt:h;await this.vault.modify(a,H(l,d))}let i=(0,x.normalizePath)(`${n}/config.md`),o=this.vault.getAbstractFileByPath(i);if(o instanceof x.TFile){let c=await this.vault.cachedRead(o),{frontmatter:l,body:h}=J(c);e.model!==void 0&&(l.model=e.model),e.adapter!==void 0&&(l.adapter=e.adapter),e.timeout!==void 0&&(l.timeout=e.timeout),e.cwd!==void 0&&(l.cwd=e.cwd),e.permissionMode!==void 0&&(l.permission_mode=e.permissionMode),e.effort!==void 0&&(l.effort=e.effort||void 0),e.approvalRequired!==void 0&&(l.approval_required=e.approvalRequired),e.memory!==void 0&&(l.memory=e.memory),e.memoryTokenBudget!==void 0&&(l.memory_token_budget=e.memoryTokenBudget),e.reflectionEnabled!==void 0&&(l.reflection_enabled=e.reflectionEnabled),e.reflectionSchedule!==void 0&&(l.reflection_schedule=e.reflectionSchedule),e.reflectionProposeSkills!==void 0&&(l.reflection_propose_skills=e.reflectionProposeSkills),e.autoCompactThreshold!==void 0&&(l.auto_compact_threshold=e.autoCompactThreshold),e.wikiReferences!==void 0&&(l.wiki_references=e.wikiReferences.length>0?e.wikiReferences.map(d=>({agent:d})):void 0),delete l.allowed_tools,delete l.blocked_tools,await this.vault.modify(o,H(l,h))}if(e.skillsBody!==void 0){let c=(0,x.normalizePath)(`${n}/SKILLS.md`),l=this.vault.getAbstractFileByPath(c);l instanceof x.TFile?await this.vault.modify(l,H({},e.skillsBody)):await this.vault.create(c,H({},e.skillsBody))}if(e.contextBody!==void 0){let c=(0,x.normalizePath)(`${n}/CONTEXT.md`),l=this.vault.getAbstractFileByPath(c);l instanceof x.TFile?await this.vault.modify(l,H({},e.contextBody)):await this.vault.create(c,H({},e.contextBody))}if(e.permissionRules!==void 0){let c=(0,x.normalizePath)(`${n}/permissions.json`),l=this.vault.getAbstractFileByPath(c),h=e.permissionRules;if(h.allow.length>0||h.deny.length>0){let d=JSON.stringify(h,null,2)+` -`;l instanceof x.TFile?await this.vault.modify(l,d):await this.vault.create(c,d)}else l instanceof x.TFile&&await this.app.fileManager.trashFile(l)}}else{let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof x.TFile))return;let a=await this.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);e.description!==void 0&&(i.description=e.description||void 0),e.avatar!==void 0&&(i.avatar=e.avatar||void 0),e.tags!==void 0&&(i.tags=e.tags),e.skills!==void 0&&(i.skills=e.skills),e.mcpServers!==void 0&&(i.mcp_servers=e.mcpServers.length>0?e.mcpServers:void 0),e.enabled!==void 0&&(i.enabled=e.enabled),e.model!==void 0&&(i.model=e.model),e.adapter!==void 0&&(i.adapter=e.adapter),e.timeout!==void 0&&(i.timeout=e.timeout),e.cwd!==void 0&&(i.cwd=e.cwd),e.permissionMode!==void 0&&(i.permission_mode=e.permissionMode),e.effort!==void 0&&(i.effort=e.effort||void 0),e.approvalRequired!==void 0&&(i.approval_required=e.approvalRequired),e.memory!==void 0&&(i.memory=e.memory),e.autoCompactThreshold!==void 0&&(i.auto_compact_threshold=e.autoCompactThreshold),e.wikiReferences!==void 0&&(i.wiki_references=e.wikiReferences.length>0?e.wikiReferences.map(l=>({agent:l})):void 0),delete i.allowed_tools,delete i.blocked_tools;let c=e.systemPrompt!==void 0?e.systemPrompt:o;if(await this.vault.modify(n,H(i,c)),e.permissionRules!==void 0){let l=Dr(s.filePath),h=this.vault.getAbstractFileByPath(l),d=e.permissionRules;if(d.allow.length>0||d.deny.length>0){let u=JSON.stringify(d,null,2)+` -`;h instanceof x.TFile?await this.vault.modify(h,u):await this.vault.create(l,u)}else h instanceof x.TFile&&await this.app.fileManager.trashFile(h)}}}async updateTask(t,e){let s=this.getTaskById(t);if(!s)return;let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof x.TFile))return;let a=await this.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);e.agent!==void 0&&(i.agent=e.agent),e.type!==void 0&&(i.type=e.type),e.schedule!==void 0&&(i.schedule=e.schedule||void 0),e.runAt!==void 0&&(i.run_at=e.runAt||void 0),e.enabled!==void 0&&(i.enabled=e.enabled),e.priority!==void 0&&(i.priority=e.priority),e.catch_up!==void 0&&(i.catch_up=e.catch_up),e.effort!==void 0&&(i.effort=e.effort||void 0),e.model!==void 0&&(i.model=e.model||void 0),e.channel!==void 0&&(i.channel=e.channel||void 0),e.channelTarget!==void 0&&(i.channel_target=e.channelTarget||void 0),e.tags!==void 0&&(i.tags=e.tags);let c=e.body!==void 0?e.body:o;await this.vault.modify(n,H(i,c))}async updateSkill(t,e){let s=this.getSkillByName(t);if(s)if(s.isFolder){let n=(0,x.normalizePath)(s.filePath.replace(/\/skill\.md$/,"")),a=this.vault.getAbstractFileByPath(s.filePath);if(a instanceof x.TFile){let i=await this.vault.cachedRead(a),{frontmatter:o,body:c}=J(i);e.description!==void 0&&(o.description=e.description||void 0),e.tags!==void 0&&(o.tags=e.tags.length>0?e.tags:void 0);let l=e.body!==void 0?e.body:c;await this.vault.modify(a,H(o,l))}if(e.toolsBody!==void 0){let i=(0,x.normalizePath)(`${n}/tools.md`),o=this.vault.getAbstractFileByPath(i);e.toolsBody&&(o instanceof x.TFile?await this.vault.modify(o,`# Tools +${e.examplesBody}`)}await this.store.loadFile(n)}async updateAgent(e,t){let s=this.store.getAgentByName(e);if(s){if(s.isFolder){let n=(0,N.normalizePath)(s.filePath.replace(/\/agent\.md$/,"")),r=this.vault.getAbstractFileByPath(s.filePath);if(r instanceof N.TFile){let l=await this.vault.cachedRead(r),{frontmatter:c,body:d}=W(l);t.description!==void 0&&(c.description=t.description||void 0),t.avatar!==void 0&&(c.avatar=t.avatar||void 0),t.tags!==void 0&&(c.tags=t.tags),t.skills!==void 0&&(c.skills=t.skills),t.mcpServers!==void 0&&(c.mcp_servers=t.mcpServers.length>0?t.mcpServers:void 0),t.enabled!==void 0&&(c.enabled=t.enabled),t.model!==void 0&&t.model!=="default"&&(c.model=t.model);let u=t.systemPrompt!==void 0?t.systemPrompt:d;await this.vault.modify(r,U(c,u))}let i=(0,N.normalizePath)(`${n}/config.md`),o=this.vault.getAbstractFileByPath(i);if(o instanceof N.TFile){let l=await this.vault.cachedRead(o),{frontmatter:c,body:d}=W(l);t.model!==void 0&&(c.model=t.model),t.adapter!==void 0&&(c.adapter=t.adapter),t.timeout!==void 0&&(c.timeout=t.timeout),t.cwd!==void 0&&(c.cwd=t.cwd),t.permissionMode!==void 0&&(c.permission_mode=t.permissionMode),t.effort!==void 0&&(c.effort=t.effort||void 0),t.approvalRequired!==void 0&&(c.approval_required=t.approvalRequired),t.memory!==void 0&&(c.memory=t.memory),t.memoryTokenBudget!==void 0&&(c.memory_token_budget=t.memoryTokenBudget),t.reflectionEnabled!==void 0&&(c.reflection_enabled=t.reflectionEnabled),t.reflectionSchedule!==void 0&&(c.reflection_schedule=t.reflectionSchedule),t.reflectionProposeSkills!==void 0&&(c.reflection_propose_skills=t.reflectionProposeSkills),t.autoCompactThreshold!==void 0&&(c.auto_compact_threshold=t.autoCompactThreshold),t.wikiReferences!==void 0&&(c.wiki_references=t.wikiReferences.length>0?t.wikiReferences.map(u=>({agent:u})):void 0),delete c.allowed_tools,delete c.blocked_tools,await this.vault.modify(o,U(c,d))}if(t.skillsBody!==void 0){let l=(0,N.normalizePath)(`${n}/SKILLS.md`),c=this.vault.getAbstractFileByPath(l);c instanceof N.TFile?await this.vault.modify(c,U({},t.skillsBody)):await this.vault.create(l,U({},t.skillsBody))}if(t.contextBody!==void 0){let l=(0,N.normalizePath)(`${n}/CONTEXT.md`),c=this.vault.getAbstractFileByPath(l);c instanceof N.TFile?await this.vault.modify(c,U({},t.contextBody)):await this.vault.create(l,U({},t.contextBody))}if(t.permissionRules!==void 0){let l=(0,N.normalizePath)(`${n}/permissions.json`),c=this.vault.getAbstractFileByPath(l),d=t.permissionRules;if(d.allow.length>0||d.deny.length>0){let u=JSON.stringify(d,null,2)+` +`;c instanceof N.TFile?await this.vault.modify(c,u):await this.vault.create(l,u)}else c instanceof N.TFile&&await this.app.fileManager.trashFile(c)}}else{let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof N.TFile))return;let r=await this.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);t.description!==void 0&&(i.description=t.description||void 0),t.avatar!==void 0&&(i.avatar=t.avatar||void 0),t.tags!==void 0&&(i.tags=t.tags),t.skills!==void 0&&(i.skills=t.skills),t.mcpServers!==void 0&&(i.mcp_servers=t.mcpServers.length>0?t.mcpServers:void 0),t.enabled!==void 0&&(i.enabled=t.enabled),t.model!==void 0&&(i.model=t.model),t.adapter!==void 0&&(i.adapter=t.adapter),t.timeout!==void 0&&(i.timeout=t.timeout),t.cwd!==void 0&&(i.cwd=t.cwd),t.permissionMode!==void 0&&(i.permission_mode=t.permissionMode),t.effort!==void 0&&(i.effort=t.effort||void 0),t.approvalRequired!==void 0&&(i.approval_required=t.approvalRequired),t.memory!==void 0&&(i.memory=t.memory),t.autoCompactThreshold!==void 0&&(i.auto_compact_threshold=t.autoCompactThreshold),t.wikiReferences!==void 0&&(i.wiki_references=t.wikiReferences.length>0?t.wikiReferences.map(c=>({agent:c})):void 0),delete i.allowed_tools,delete i.blocked_tools;let l=t.systemPrompt!==void 0?t.systemPrompt:o;if(await this.vault.modify(n,U(i,l)),t.permissionRules!==void 0){let c=Os(s.filePath),d=this.vault.getAbstractFileByPath(c),u=t.permissionRules;if(u.allow.length>0||u.deny.length>0){let h=JSON.stringify(u,null,2)+` +`;d instanceof N.TFile?await this.vault.modify(d,h):await this.vault.create(c,h)}else d instanceof N.TFile&&await this.app.fileManager.trashFile(d)}}await this.store.loadFile(s.filePath)}}async updateTask(e,t){let s=this.store.getTaskById(e);if(!s)return;let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof N.TFile))return;let r=await this.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);t.agent!==void 0&&(i.agent=t.agent),t.type!==void 0&&(i.type=t.type),t.schedule!==void 0&&(i.schedule=t.schedule||void 0),t.runAt!==void 0&&(i.run_at=t.runAt||void 0),t.enabled!==void 0&&(i.enabled=t.enabled),t.priority!==void 0&&(i.priority=t.priority),t.catch_up!==void 0&&(i.catch_up=t.catch_up),t.effort!==void 0&&(i.effort=t.effort||void 0),t.model!==void 0&&(i.model=t.model||void 0),t.channel!==void 0&&(i.channel=t.channel||void 0),t.channelTarget!==void 0&&(i.channel_target=t.channelTarget||void 0),t.tags!==void 0&&(i.tags=t.tags);let l=t.body!==void 0?t.body:o;await this.vault.modify(n,U(i,l)),await this.store.loadFile(n)}async updateSkill(e,t){let s=this.store.getSkillByName(e);if(s){if(s.isFolder){let n=(0,N.normalizePath)(s.filePath.replace(/\/skill\.md$/,"")),r=this.vault.getAbstractFileByPath(s.filePath);if(r instanceof N.TFile){let i=await this.vault.cachedRead(r),{frontmatter:o,body:l}=W(i);t.description!==void 0&&(o.description=t.description||void 0),t.tags!==void 0&&(o.tags=t.tags.length>0?t.tags:void 0);let c=t.body!==void 0?t.body:l;await this.vault.modify(r,U(o,c))}if(t.toolsBody!==void 0){let i=(0,N.normalizePath)(`${n}/tools.md`),o=this.vault.getAbstractFileByPath(i);t.toolsBody&&(o instanceof N.TFile?await this.vault.modify(o,`# Tools -${e.toolsBody}`):await this.vault.create(i,`# Tools +${t.toolsBody}`):await this.vault.create(i,`# Tools -${e.toolsBody}`))}if(e.referencesBody!==void 0){let i=(0,x.normalizePath)(`${n}/references.md`),o=this.vault.getAbstractFileByPath(i);e.referencesBody&&(o instanceof x.TFile?await this.vault.modify(o,`# References +${t.toolsBody}`))}if(t.referencesBody!==void 0){let i=(0,N.normalizePath)(`${n}/references.md`),o=this.vault.getAbstractFileByPath(i);t.referencesBody&&(o instanceof N.TFile?await this.vault.modify(o,`# References -${e.referencesBody}`):await this.vault.create(i,`# References +${t.referencesBody}`):await this.vault.create(i,`# References -${e.referencesBody}`))}if(e.examplesBody!==void 0){let i=(0,x.normalizePath)(`${n}/examples.md`),o=this.vault.getAbstractFileByPath(i);e.examplesBody&&(o instanceof x.TFile?await this.vault.modify(o,`# Examples +${t.referencesBody}`))}if(t.examplesBody!==void 0){let i=(0,N.normalizePath)(`${n}/examples.md`),o=this.vault.getAbstractFileByPath(i);t.examplesBody&&(o instanceof N.TFile?await this.vault.modify(o,`# Examples -${e.examplesBody}`):await this.vault.create(i,`# Examples +${t.examplesBody}`):await this.vault.create(i,`# Examples -${e.examplesBody}`))}}else{let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof x.TFile))return;let a=await this.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);e.description!==void 0&&(i.description=e.description||void 0),e.tags!==void 0&&(i.tags=e.tags.length>0?e.tags:void 0);let c=e.body!==void 0?e.body:o;await this.vault.modify(n,H(i,c))}}async deleteSkill(t){let e=this.getSkillByName(t);if(e)if(e.isFolder){let s=(0,x.normalizePath)(e.filePath.replace(/\/skill\.md$/,"")),n=this.vault.getAbstractFileByPath(s);n instanceof x.TFolder&&await this.app.fileManager.trashFile(n)}else await this.trashFile(e.filePath)}async deleteTask(t){let e=this.getTaskById(t);e&&await this.trashFile(e.filePath)}async updateChannel(t,e){let s=this.getChannelByName(t);if(!s)return;let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof x.TFile))return;let a=await this.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);e.default_agent!==void 0&&(i.default_agent=e.default_agent,delete i.agent),e.allowed_agents!==void 0&&(i.allowed_agents=e.allowed_agents),e.enabled!==void 0&&(i.enabled=e.enabled),e.credential_ref!==void 0&&(i.credential_ref=e.credential_ref),e.allowed_users!==void 0&&(i.allowed_users=e.allowed_users),e.per_user_sessions!==void 0&&(i.per_user_sessions=e.per_user_sessions),e.channel_context!==void 0&&(i.channel_context=e.channel_context||void 0),e.tags!==void 0&&(i.tags=e.tags),e.type!==void 0&&(i.type=e.type),e.transport!==void 0&&(i.transport=e.transport);let c=e.body!==void 0?e.body:o;await this.vault.modify(n,H(i,c))}async deleteChannel(t){let e=this.getChannelByName(t);if(!e)return;await this.trashFile(e.filePath);let s=(0,x.normalizePath)(`${this.getSubfolder("channels")}/${oe(t)}/sessions`),n=this.vault.getAbstractFileByPath(s);n instanceof x.TFolder&&await this.app.fileManager.trashFile(n)}mcpServerFrontmatter(t){let e={name:t.name,transport:t.type,enabled:t.enabled};return t.source&&(e.source=t.source),t.type==="stdio"?(t.command&&(e.command=t.command),t.args&&t.args.length>0&&(e.args=t.args),t.env&&Object.keys(t.env).length>0&&(e.env=t.env),t.envSecretKeys&&t.envSecretKeys.length>0&&(e.env_secret_keys=t.envSecretKeys)):(t.url&&(e.url=t.url),t.headers&&Object.keys(t.headers).length>0&&(e.headers=t.headers),t.auth&&(e.auth=t.auth),t.oauth&&(t.oauth.clientId||t.oauth.resource||(t.oauth.scopes?.length??0)>0)&&(e.oauth={client_id:t.oauth.clientId||void 0,resource:t.oauth.resource||void 0,scopes:t.oauth.scopes&&t.oauth.scopes.length>0?t.oauth.scopes:void 0})),e}async saveMcpServer(t,e=""){let s=this.mcpServerFrontmatter(t),n=H(s,e.trim()),a=t.filePath?this.vault.getAbstractFileByPath(t.filePath):null;if(a instanceof x.TFile)return await this.vault.modify(a,n),a.path;let i=await this.getAvailablePath(this.getSubfolder("mcp"),oe(t.name));return await this.ensureFolder(this.getSubfolder("mcp")),await this.vault.create(i,n),i}async setMcpServerEnabled(t,e){let s=this.getMcpServerByName(t);if(!s?.filePath)return;let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof x.TFile))return;let a=await this.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);i.enabled=e,await this.vault.modify(n,H(i,o))}async deleteMcpServer(t){let e=this.getMcpServerByName(t);e?.filePath&&await this.trashFile(e.filePath)}async updateHeartbeat(t,e){let s=this.getAgentByName(t);if(!s||!s.isFolder)return;let n=(0,x.normalizePath)(s.filePath.replace(/\/agent\.md$/,"")),a=(0,x.normalizePath)(`${n}/HEARTBEAT.md`),i=this.vault.getAbstractFileByPath(a);if(i instanceof x.TFile){let o=await this.vault.cachedRead(i),{frontmatter:c,body:l}=J(o);e.enabled!==void 0&&(c.enabled=e.enabled),e.schedule!==void 0&&(c.schedule=e.schedule||void 0),e.notify!==void 0&&(c.notify=e.notify),e.channel!==void 0&&(c.channel=e.channel||void 0),e.channelTarget!==void 0&&(c.channel_target=e.channelTarget||void 0);let h=e.body!==void 0?e.body:l;await this.vault.modify(i,H(c,h))}else{let o={enabled:e.enabled??!1};e.schedule&&(o.schedule=e.schedule),e.notify!==void 0&&(o.notify=e.notify),e.channel&&(o.channel=e.channel),e.channelTarget&&(o.channel_target=e.channelTarget);let c=e.body??"";await this.vault.create(a,H(o,c))}}async deleteAgent(t,e){let s=[],n=this.getAgentByName(t);if(!n)return{trashedFiles:s};if(n.isFolder){let c=(0,x.normalizePath)(n.filePath.replace(/\/agent\.md$/,"")),l=this.vault.getAbstractFileByPath(c);if(l instanceof x.TFolder){let h=[];this.collectMarkdownChildren(l,h);for(let d of h)s.push(d.path);await this.app.fileManager.trashFile(l)}}else await this.trashFile(n.filePath),s.push(n.filePath);let a=this.getMemoryPath(t);this.vault.getAbstractFileByPath(a)&&(await this.trashFile(a),s.push(a));let i=this.getMemoryDir(t),o=this.vault.getAbstractFileByPath(i);if(o instanceof x.TFolder&&(await this.app.fileManager.trashFile(o),s.push(i)),e){let c=this.getTasksForAgent(t);for(let l of c)await this.trashFile(l.filePath),s.push(l.filePath)}return{trashedFiles:s}}async trashFile(t){let e=this.vault.getAbstractFileByPath(t);e&&await this.app.fileManager.trashFile(e)}async ensureFolder(t){if(!this.vault.getAbstractFileByPath(t))try{await this.vault.createFolder(t)}catch(e){if(!(e instanceof Error?e.message:String(e)).includes("Folder already exists"))throw e}}async getAvailablePath(t,e){let s=0;for(;;){let n=s===0?"":`-${s+1}`,a=(0,x.normalizePath)(`${t}/${e}${n}.md`);if(!this.vault.getAbstractFileByPath(a))return a;s+=1}}async createFileIfMissing(t,e){if(!this.vault.getAbstractFileByPath(t))try{await this.vault.create(t,e)}catch(s){if(!(s instanceof Error?s.message:String(s)).includes("File already exists"))throw s}}collectMarkdownChildren(t,e){for(let s of t.children)s instanceof x.TFile&&s.extension==="md"&&e.push(s),s instanceof x.TFolder&&this.collectMarkdownChildren(s,e)}clearStoredFile(t){this.agents.delete(t),this.skills.delete(t),this.tasks.delete(t),this.channels.delete(t),this.mcpServers.delete(t),this.validationIssues.delete(t)}setIssue(t,e){let s=this.validationIssues.get(t)??[];s.push({path:t,message:e}),this.validationIssues.set(t,s)}parseFile(t,e){return t.startsWith(`${this.getSubfolder("agents")}/`)?this.parseAgent(t,e):t.startsWith(`${this.getSubfolder("skills")}/`)?this.parseSkill(t,e):t.startsWith(`${this.getSubfolder("tasks")}/`)?this.parseTask(t,e):null}parseAgent(t,e){let{frontmatter:s,body:n}=J(e);if(!Ht(s))return this.setIssue(t,"Invalid frontmatter."),null;let a=A(s.name),i=A(s.model)??this.settings.defaultModel;if(!a||!i)return this.setIssue(t,"Agent requires string field `name` and a valid model or default model setting."),null;let o=ue(s.allowed_tools),c=ue(s.blocked_tools);return(o.length>0||c.length>0)&&!this.warnedLegacyPerms.has(a)&&(this.warnedLegacyPerms.add(a),console.warn(`Agent Fleet: "${a}" still uses legacy allowed_tools/blocked_tools in its frontmatter. Permission rules now live in .permissions.json beside the .md. Open Edit and Save to migrate.`)),{filePath:t,name:a,description:A(s.description),model:i,adapter:A(s.adapter)??"claude-code",permissionMode:A(s.permission_mode)??"bypassPermissions",effort:A(s.effort),maxRetries:Re(s.max_retries,1),skills:ue(s.skills),mcpServers:ue(s.mcp_servers),cwd:A(s.cwd),enabled:Be(s.enabled,!0),timeout:Re(s.timeout,300),approvalRequired:ue(s.approval_required),memory:Be(s.memory,!1),memoryMaxEntries:Re(s.memory_max_entries,100),memoryTokenBudget:Pr(s),reflection:Rr(s),autoCompactThreshold:Re(s.auto_compact_threshold,85),tags:ue(s.tags),avatar:A(s.avatar)??"",body:n,contextBody:"",skillsBody:"",env:this.parseEnvMap(s.env),permissionRules:{allow:o,deny:c},isFolder:!1,heartbeatEnabled:!1,heartbeatSchedule:"",heartbeatBody:"",heartbeatNotify:!0,heartbeatChannel:"",heartbeatChannelTarget:""}}parseSkill(t,e){let{frontmatter:s,body:n}=J(e),a=A(s.name);return a?{filePath:t,name:a,description:A(s.description),tags:ue(s.tags),body:n,toolsBody:"",referencesBody:"",examplesBody:"",isFolder:!1}:(this.setIssue(t,"Skill requires string field `name`."),null)}parseTask(t,e){let{frontmatter:s,body:n}=J(e),a=A(s.task_id),i=A(s.agent),o=A(s.type);if(!a||!i||!o)return this.setIssue(t,"Task requires `task_id`, `agent`, and `type`."),null;if(o==="recurring"&&!A(s.schedule))return this.setIssue(t,"Recurring task requires `schedule`."),null;if(o==="once"&&!A(s.run_at))return this.setIssue(t,"One-time task requires `run_at`."),null;let c=A(s.priority),h=c&&["low","medium","high","critical"].includes(c)?c:"medium";return{filePath:t,taskId:a,agent:i,schedule:A(s.schedule),runAt:A(s.run_at),type:o,priority:h,enabled:Be(s.enabled,!0),created:A(s.created)??new Date().toISOString(),lastRun:A(s.last_run),nextRun:A(s.next_run),runCount:Re(s.run_count,0),catchUp:Be(s.catch_up,this.settings.catchUpMissedTasks),effort:A(s.effort),model:A(s.model),channel:A(s.channel),channelTarget:A(s.channel_target),tags:ue(s.tags),body:n}}parseChannelFile(t,e){let{frontmatter:s,body:n}=J(e),a=A(s.name);if(!a)return this.setIssue(t,"Channel requires string field `name`."),null;let i=A(s.type),o=["slack","telegram","discord"];if(!i||!o.includes(i))return this.setIssue(t,`Channel \`${a}\` requires \`type\` to be one of: ${o.join(", ")}.`),null;let c=i,l=A(s.default_agent)??A(s.agent);if(!l)return this.setIssue(t,`Channel \`${a}\` requires \`default_agent\` (or \`agent\`).`),null;let h=ue(s.allowed_agents),d=A(s.credential_ref);if(!d)return this.setIssue(t,`Channel \`${a}\` requires \`credential_ref\` pointing at a configured credential.`),null;let u=Ht(s.transport)?s.transport:{};return{filePath:t,name:a,type:c,defaultAgent:l,allowedAgents:h,enabled:Be(s.enabled,!0),credentialRef:d,allowedUsers:ue(s.allowed_users),perUserSessions:Be(s.per_user_sessions,!0),channelContext:A(s.channel_context)??"",transport:u,tags:ue(s.tags),body:n}}parseMcpServerFile(t,e){let{frontmatter:s,body:n}=J(e),a=A(s.name);if(!a)return this.setIssue(t,"MCP server requires string field `name`."),null;let i=(A(s.transport)??A(s.type)??"").toLowerCase(),o=["stdio","http","sse"];if(!o.includes(i))return this.setIssue(t,`MCP server \`${a}\` requires \`transport\` to be one of: ${o.join(", ")}.`),null;let c=i;if(c==="stdio"){if(!A(s.command))return this.setIssue(t,`MCP server \`${a}\` (stdio) requires a \`command\`.`),null}else if(!A(s.url))return this.setIssue(t,`MCP server \`${a}\` (${c}) requires a \`url\`.`),null;let l=(A(s.auth)??"").toLowerCase(),h=l==="bearer"||l==="oauth"||l==="none"?l:void 0,d;if(Ht(s.oauth)){let u=s.oauth;d={clientId:A(u.client_id)??A(u.clientId),resource:A(u.resource),scopes:ue(u.scopes)}}return{name:a,filePath:t,type:c,enabled:Be(s.enabled,!0),description:A(s.description)??(n.trim()||void 0),source:A(s.source)==="imported"?"imported":"manual",command:A(s.command),args:ue(s.args),env:Ar(s.env),envSecretKeys:ue(s.env_secret_keys),url:A(s.url),headers:Ar(s.headers),auth:h,oauth:d,status:"disconnected",scope:"user",tools:[],toolDetails:[]}}validateReferences(){let t=new Set;for(let o of this.skills.values())t.has(o.name)&&this.setIssue(o.filePath,`Duplicate skill name \`${o.name}\`.`),t.add(o.name);let e=new Set;for(let o of this.agents.values()){e.has(o.name)&&this.setIssue(o.filePath,`Duplicate agent name \`${o.name}\`.`),e.add(o.name);for(let c of o.skills)t.has(c)||this.setIssue(o.filePath,`Agent references missing skill \`${c}\`.`)}for(let o of this.tasks.values())e.has(o.agent)||this.setIssue(o.filePath,`Task references missing agent \`${o.agent}\`.`);let s=new Set,n=new Map;for(let o of this.agents.values())n.set(o.name,o);let a=this.channelCredentialGetter?.()??this.settings.channelCredentials??{};for(let o of this.channels.values()){s.has(o.name)&&this.setIssue(o.filePath,`Duplicate channel name \`${o.name}\`.`),s.add(o.name);let c=n.get(o.defaultAgent);c?c.approvalRequired.length>0&&this.setIssue(o.filePath,`Channel \`${o.name}\` cannot bind to agent \`${c.name}\` because the agent has \`approval_required\` set (${c.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`):this.setIssue(o.filePath,`Channel \`${o.name}\` references missing agent \`${o.defaultAgent}\`.`);let l=a[o.credentialRef];l?l.type!==o.type&&this.setIssue(o.filePath,`Channel \`${o.name}\` is type \`${o.type}\` but credential \`${o.credentialRef}\` is type \`${l.type}\`.`):this.setIssue(o.filePath,`Channel \`${o.name}\` references missing credential \`${o.credentialRef}\`. Add it under Settings \u2192 Channel Credentials.`)}let i=new Set;for(let o of this.mcpServers.values())i.has(o.name)&&this.setIssue(o.filePath??o.name,`Duplicate MCP server name \`${o.name}\`.`),i.add(o.name)}parseEnvMap(t){if(!Ht(t))return{};let e={};for(let[s,n]of Object.entries(t))typeof n=="string"?e[s]=n:(typeof n=="number"||typeof n=="boolean")&&(e[s]=String(n));return e}parseApprovals(t){if(Array.isArray(t))return t.flatMap(e=>{if(!Ht(e)||!A(e.tool))return[];let s=A(e.tool);return s?[{tool:s,command:A(e.command),reason:A(e.reason),status:A(e.status)??"pending",resolvedAt:A(e.resolvedAt),note:A(e.note)}]:[]})}};var Pt=require("obsidian"),Ks=class extends Pt.Modal{constructor(e,s,n){super(e);this.info=s;this.onConfirm=n}deleteTasks=!0;onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("af-confirm-delete-modal");let s=e.createDiv({cls:"af-delete-header"}),n=s.createSpan({cls:"af-delete-header-icon"});(0,Pt.setIcon)(n,"alert-triangle"),s.createEl("h3",{text:`Delete agent "${this.info.agentName}"?`});let a=e.createDiv({cls:"af-delete-summary"});a.createDiv({text:"This action will:"});let i=a.createEl("ul",{cls:"af-delete-impact-list"});i.createEl("li",{text:"Move the agent definition to trash"}),this.info.hasMemory&&i.createEl("li",{text:"Move the agent's memory file to trash"}),this.info.taskCount>0&&i.createEl("li").setText(`${this.info.taskCount} task${this.info.taskCount!==1?"s":""} reference this agent`),this.info.runCount>0&&i.createEl("li",{cls:"af-delete-preserved"}).setText(`${this.info.runCount} run log${this.info.runCount!==1?"s":""} will be preserved`),e.createDiv({cls:"af-delete-note",text:"Files are moved to your system trash and can be recovered."}),this.info.taskCount>0&&new Pt.Setting(e).setName("Also delete associated tasks").setDesc(`Delete ${this.info.taskCount} task${this.info.taskCount!==1?"s":""} that reference this agent`).addToggle(d=>{d.setValue(this.deleteTasks),d.onChange(u=>{this.deleteTasks=u})});let o=e.createDiv({cls:"af-delete-actions"}),c=o.createEl("button",{text:"Cancel"});c.onclick=()=>this.close();let l=o.createEl("button",{cls:"af-delete-confirm-btn",text:"Delete Agent"}),h=l.createSpan({cls:"af-delete-btn-icon"});(0,Pt.setIcon)(h,"trash-2"),l.onclick=()=>{this.onConfirm(this.deleteTasks),this.close()}}};var F=require("obsidian");var Js=require("obsidian"),qt=class extends Js.Modal{constructor(e,s){super(e);this.opts=s}onOpen(){let{contentEl:e}=this;e.empty(),e.createEl("h3",{text:this.opts.title});for(let s of this.opts.body.split(` +${t.examplesBody}`))}}else{let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof N.TFile))return;let r=await this.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);t.description!==void 0&&(i.description=t.description||void 0),t.tags!==void 0&&(i.tags=t.tags.length>0?t.tags:void 0);let l=t.body!==void 0?t.body:o;await this.vault.modify(n,U(i,l))}await this.store.loadFile(s.filePath)}}async deleteSkill(e){let t=this.store.getSkillByName(e);if(t){if(t.isFolder){let s=(0,N.normalizePath)(t.filePath.replace(/\/skill\.md$/,"")),n=this.vault.getAbstractFileByPath(s);n instanceof N.TFolder&&await this.app.fileManager.trashFile(n),this.store.clearIssuesUnder(s)}else await qe(this.app,t.filePath);this.store.clearStoredFile(t.filePath),this.store.validateReferences()}}async deleteTask(e){let t=this.store.getTaskById(e);t&&(await qe(this.app,t.filePath),this.store.clearStoredFile(t.filePath),this.store.validateReferences())}async updateChannel(e,t){let s=this.store.getChannelByName(e);if(!s)return;let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof N.TFile))return;let r=await this.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);t.default_agent!==void 0&&(i.default_agent=t.default_agent,delete i.agent),t.allowed_agents!==void 0&&(i.allowed_agents=t.allowed_agents),t.enabled!==void 0&&(i.enabled=t.enabled),t.credential_ref!==void 0&&(i.credential_ref=t.credential_ref),t.allowed_users!==void 0&&(i.allowed_users=t.allowed_users),t.per_user_sessions!==void 0&&(i.per_user_sessions=t.per_user_sessions),t.channel_context!==void 0&&(i.channel_context=t.channel_context||void 0),t.tags!==void 0&&(i.tags=t.tags),t.type!==void 0&&(i.type=t.type),t.transport!==void 0&&(i.transport=t.transport);let l=t.body!==void 0?t.body:o;await this.vault.modify(n,U(i,l)),await this.store.loadFile(n)}async deleteChannel(e){let t=this.store.getChannelByName(e);if(!t)return;await qe(this.app,t.filePath),this.store.clearStoredFile(t.filePath),this.store.validateReferences();let s=(0,N.normalizePath)(`${this.deps.getSubfolder("channels")}/${z(e)}/sessions`),n=this.vault.getAbstractFileByPath(s);n instanceof N.TFolder&&await this.app.fileManager.trashFile(n)}async updateHeartbeat(e,t){let s=this.store.getAgentByName(e);if(!s||!s.isFolder)return;let n=(0,N.normalizePath)(s.filePath.replace(/\/agent\.md$/,"")),r=(0,N.normalizePath)(`${n}/HEARTBEAT.md`),i=this.vault.getAbstractFileByPath(r);if(i instanceof N.TFile){let o=await this.vault.cachedRead(i),{frontmatter:l,body:c}=W(o);t.enabled!==void 0&&(l.enabled=t.enabled),t.schedule!==void 0&&(l.schedule=t.schedule||void 0),t.notify!==void 0&&(l.notify=t.notify),t.channel!==void 0&&(l.channel=t.channel||void 0),t.channelTarget!==void 0&&(l.channel_target=t.channelTarget||void 0);let d=t.body!==void 0?t.body:c;await this.vault.modify(i,U(l,d))}else{let o={enabled:t.enabled??!1};t.schedule&&(o.schedule=t.schedule),t.notify!==void 0&&(o.notify=t.notify),t.channel&&(o.channel=t.channel),t.channelTarget&&(o.channel_target=t.channelTarget);let l=t.body??"";await this.vault.create(r,U(o,l))}await this.store.loadFile(r)}async deleteAgent(e,t){let s=[],n=this.store.getAgentByName(e);if(!n)return{trashedFiles:s};if(n.isFolder){let l=(0,N.normalizePath)(n.filePath.replace(/\/agent\.md$/,"")),c=this.vault.getAbstractFileByPath(l);if(c instanceof N.TFolder){let d=[];ps(c,d);for(let u of d)s.push(u.path);await this.app.fileManager.trashFile(c)}this.store.clearIssuesUnder(l)}else await qe(this.app,n.filePath),s.push(n.filePath),this.store.clearIssuesFor(Os(n.filePath));this.store.clearStoredFile(n.filePath);let r=this.deps.getMemoryPath(e);this.vault.getAbstractFileByPath(r)&&(await qe(this.app,r),s.push(r));let i=this.deps.getMemoryDir(e),o=this.vault.getAbstractFileByPath(i);if(o instanceof N.TFolder&&(await this.app.fileManager.trashFile(o),s.push(i)),t){let l=this.store.getTasksForAgent(e);for(let c of l)await qe(this.app,c.filePath),s.push(c.filePath),this.store.clearStoredFile(c.filePath)}return this.store.validateReferences(),{trashedFiles:s}}};var Ce=require("obsidian");var Ns=class extends Map{constructor(t){super();this.onMutate=t}set(t,s){return this.onMutate(),super.set(t,s)}delete(t){return this.onMutate(),super.delete(t)}clear(){this.onMutate(),super.clear()}},Cn=class{constructor(e,t){this.deps=t;this.vault=e.vault,this.settings=t.settings,this.parserCtx={vault:this.vault,settings:this.settings,reportIssue:(s,n)=>this.setIssue(s,n),clearIssue:s=>{this.validationIssues.delete(s)},warnedLegacyPerms:this.warnedLegacyPerms,warnedFolderAgentModelConflict:this.warnedFolderAgentModelConflict}}agents=new Ns(()=>{this.nameIndexesStale=!0});skills=new Ns(()=>{this.nameIndexesStale=!0});tasks=new Ns(()=>{this.nameIndexesStale=!0});channels=new Map;mcpServers=new Map;validationIssues=new Map;referenceIssues=new Map;bulkLoading=!1;nameIndexesStale=!0;agentsByName=new Map;skillsByName=new Map;tasksById=new Map;channelCredentialGetter;warnedFolderAgentModelConflict=new Set;warnedLegacyPerms=new Set;vault;settings;parserCtx;setChannelCredentialGetter(e){this.channelCredentialGetter=e}async loadAll(){this.agents.clear(),this.skills.clear(),this.tasks.clear(),this.channels.clear(),this.mcpServers.clear(),this.validationIssues.clear(),this.referenceIssues.clear(),this.bulkLoading=!0;try{await this.loadFolderAgents(),await this.loadFolderSkills();let e=this.vault.getMarkdownFiles().filter(t=>t.path.startsWith(`${this.deps.getFleetRoot()}/`));for(let t of e)await this.loadFile(t)}finally{this.bulkLoading=!1}return this.validateReferences(),this.getSnapshot()}async loadFile(e){await this.loadFileInternal(e),this.bulkLoading||this.validateReferences()}async loadFileInternal(e){let t=typeof e=="string"?this.vault.getAbstractFileByPath(e):e;if(!(t instanceof Ce.TFile)||t.extension!=="md")return;if(this.isInsideAgentFolder(t.path)){await this.reloadFolderAgentContaining(t.path);return}if(this.isInsideSkillFolder(t.path)){await this.reloadFolderSkillContaining(t.path);return}this.clearStoredFile(t.path);let s=`${this.deps.getSubfolder("channels")}/`;if(t.path.startsWith(s)){if(!t.path.slice(s.length).includes("/")){let l=await this.vault.cachedRead(t),c=oi(this.parserCtx,t.path,l);c&&this.channels.set(t.path,c)}return}let n=`${this.deps.getSubfolder("mcp")}/`;if(t.path.startsWith(n)){if(!t.path.slice(n.length).includes("/")){let l=await this.vault.cachedRead(t),c=this.deps.parseMcpServerFile(t.path,l);c&&this.mcpServers.set(t.path,c)}return}let r=await this.vault.cachedRead(t),i=this.parseFile(t.path,r);if(i)if("taskId"in i)this.tasks.set(t.path,i);else if("model"in i){if(!i.isFolder){let o=Os(t.path);this.validationIssues.delete(o);let l=this.vault.getAbstractFileByPath(o);if(l instanceof Ce.TFile)try{let c=await this.vault.cachedRead(l),d=JSON.parse(c);i.permissionRules={allow:Q(d.allow),deny:Q(d.deny)}}catch(c){console.error(`Agent Fleet: invalid JSON in ${o} \u2014 agent "${i.name}" falls back to legacy frontmatter permission rules`,c),this.setIssue(o,`Invalid JSON in ${o} for agent \`${i.name}\` \u2014 sidecar permission rules are NOT applied.`)}}this.agents.set(t.path,i)}else this.skills.set(t.path,i)}async reloadFolderAgentContaining(e){let t=`${this.deps.getSubfolder("agents")}/`,n=e.slice(t.length).split("/")[0];if(!n)return;let r=(0,Ce.normalizePath)(`${t}${n}`),i=(0,Ce.normalizePath)(`${r}/agent.md`);if(this.agents.delete(i),!(this.vault.getAbstractFileByPath(r)instanceof Ce.TFolder))return;let l=this.vault.getAbstractFileByPath(i);if(!(l instanceof Ce.TFile))return;let c=await Ua(this.parserCtx,r,l);c&&this.agents.set(i,c)}isInsideAgentFolder(e){let t=`${this.deps.getSubfolder("agents")}/`;return e.startsWith(t)?e.slice(t.length).includes("/"):!1}isInsideSkillFolder(e){let t=`${this.deps.getSubfolder("skills")}/`;return e.startsWith(t)?e.slice(t.length).includes("/"):!1}async reloadFolderSkillContaining(e){let t=`${this.deps.getSubfolder("skills")}/`,n=e.slice(t.length).split("/")[0];if(!n)return;let r=(0,Ce.normalizePath)(`${t}${n}`),i=(0,Ce.normalizePath)(`${r}/skill.md`);if(this.skills.delete(i),!(this.vault.getAbstractFileByPath(r)instanceof Ce.TFolder))return;let l=this.vault.getAbstractFileByPath(i);if(!(l instanceof Ce.TFile))return;let c=await Ba(this.parserCtx,r,l);c&&this.skills.set(i,c)}async loadFolderSkills(){let e=this.vault.getAbstractFileByPath(this.deps.getSubfolder("skills"));if(e instanceof Ce.TFolder)for(let t of e.children){if(!(t instanceof Ce.TFolder))continue;let s=(0,Ce.normalizePath)(`${t.path}/skill.md`),n=this.vault.getAbstractFileByPath(s);if(!(n instanceof Ce.TFile))continue;let r=await Ba(this.parserCtx,t.path,n);r&&this.skills.set(s,r)}}async loadFolderAgents(){let e=this.vault.getAbstractFileByPath(this.deps.getSubfolder("agents"));if(e instanceof Ce.TFolder)for(let t of e.children){if(!(t instanceof Ce.TFolder))continue;let s=(0,Ce.normalizePath)(`${t.path}/agent.md`),n=this.vault.getAbstractFileByPath(s);if(!(n instanceof Ce.TFile))continue;let r=await Ua(this.parserCtx,t.path,n);r&&this.agents.set(s,r)}}removeFile(e){this.clearStoredFile(e),this.bulkLoading||this.validateReferences()}getSnapshot(){return{agents:Array.from(this.agents.values()).sort((e,t)=>e.name.localeCompare(t.name)),skills:Array.from(this.skills.values()).sort((e,t)=>e.name.localeCompare(t.name)),tasks:Array.from(this.tasks.values()).sort((e,t)=>e.taskId.localeCompare(t.taskId)),channels:Array.from(this.channels.values()).sort((e,t)=>e.name.localeCompare(t.name)),mcpServers:Array.from(this.mcpServers.values()).sort((e,t)=>e.name.localeCompare(t.name)),validationIssues:[...Array.from(this.validationIssues.values()).flat(),...Array.from(this.referenceIssues.values()).flat()]}}rebuildNameIndexes(){if(this.nameIndexesStale){this.agentsByName.clear(),this.skillsByName.clear(),this.tasksById.clear();for(let e of this.agents.values())this.agentsByName.has(e.name)||this.agentsByName.set(e.name,e);for(let e of this.skills.values())this.skillsByName.has(e.name)||this.skillsByName.set(e.name,e);for(let e of this.tasks.values())this.tasksById.has(e.taskId)||this.tasksById.set(e.taskId,e);this.nameIndexesStale=!1}}getAgentByName(e){return this.rebuildNameIndexes(),this.agentsByName.get(e)}getSkillByName(e){return this.rebuildNameIndexes(),this.skillsByName.get(e)}getTaskById(e){return this.rebuildNameIndexes(),this.tasksById.get(e)}getTasksForAgent(e){return Array.from(this.tasks.values()).filter(t=>t.agent===e)}getChannelByName(e){return Array.from(this.channels.values()).find(t=>t.name===e)}getChannelsForAgent(e){return Array.from(this.channels.values()).filter(t=>t.defaultAgent===e)}getMcpServers(){return Array.from(this.mcpServers.values()).sort((e,t)=>e.name.localeCompare(t.name))}getMcpServerByName(e){return Array.from(this.mcpServers.values()).find(t=>t.name===e)}clearStoredFile(e){this.agents.delete(e),this.skills.delete(e),this.tasks.delete(e),this.channels.delete(e),this.mcpServers.delete(e),this.validationIssues.delete(e),this.referenceIssues.delete(e)}clearIssuesUnder(e){let t=`${e}/`;for(let s of[...this.validationIssues.keys()])s.startsWith(t)&&this.validationIssues.delete(s);for(let s of[...this.referenceIssues.keys()])s.startsWith(t)&&this.referenceIssues.delete(s)}clearIssuesFor(e){this.validationIssues.delete(e),this.referenceIssues.delete(e)}setIssue(e,t){let s=this.validationIssues.get(e)??[];s.push({path:e,message:t}),this.validationIssues.set(e,s)}setReferenceIssue(e,t){let s=this.referenceIssues.get(e)??[];s.push({path:e,message:t}),this.referenceIssues.set(e,s)}parseFile(e,t){return e.startsWith(`${this.deps.getSubfolder("agents")}/`)?ai(this.parserCtx,e,t):e.startsWith(`${this.deps.getSubfolder("skills")}/`)?ri(this.parserCtx,e,t):e.startsWith(`${this.deps.getSubfolder("tasks")}/`)?ii(this.parserCtx,e,t):null}validateReferences(){this.referenceIssues.clear();let e=new Set;for(let o of this.skills.values())e.has(o.name)&&this.setReferenceIssue(o.filePath,`Duplicate skill name \`${o.name}\`.`),e.add(o.name);let t=new Set;for(let o of this.agents.values()){t.has(o.name)&&this.setReferenceIssue(o.filePath,`Duplicate agent name \`${o.name}\`.`),t.add(o.name);for(let l of o.skills)e.has(l)||this.setReferenceIssue(o.filePath,`Agent references missing skill \`${l}\`.`)}for(let o of this.tasks.values())t.has(o.agent)||this.setReferenceIssue(o.filePath,`Task references missing agent \`${o.agent}\`.`);let s=new Set,n=new Map;for(let o of this.agents.values())n.set(o.name,o);let r=this.channelCredentialGetter?.()??this.settings.channelCredentials??{};for(let o of this.channels.values()){s.has(o.name)&&this.setReferenceIssue(o.filePath,`Duplicate channel name \`${o.name}\`.`),s.add(o.name);let l=n.get(o.defaultAgent);l?l.approvalRequired.length>0&&this.setReferenceIssue(o.filePath,`Channel \`${o.name}\` cannot bind to agent \`${l.name}\` because the agent has \`approval_required\` set (${l.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`):this.setReferenceIssue(o.filePath,`Channel \`${o.name}\` references missing agent \`${o.defaultAgent}\`.`);let c=r[o.credentialRef];c?c.type!==o.type&&this.setReferenceIssue(o.filePath,`Channel \`${o.name}\` is type \`${o.type}\` but credential \`${o.credentialRef}\` is type \`${c.type}\`.`):this.setReferenceIssue(o.filePath,`Channel \`${o.name}\` references missing credential \`${o.credentialRef}\`. Add it under Settings \u2192 Channel Credentials.`)}let i=new Set;for(let o of this.mcpServers.values())i.has(o.name)&&this.setReferenceIssue(o.filePath??o.name,`Duplicate MCP server name \`${o.name}\`.`),i.add(o.name)}};var xi=require("path"),ve=require("obsidian");var Kt=2,di=["Preferences","Procedures","Observations","Recent"],ui="Recent",ja=/\[REMEMBER(?::([a-zA-Z]+))?\]([\s\S]*?)\[\/REMEMBER\]/g;function zc(a){switch((a??"").toLowerCase()){case"pin":case"preference":case"preferences":return{pinned:!0,section:"Preferences"};case"procedure":case"procedures":return{pinned:!1,section:"Procedures"};case"observation":case"observations":return{pinned:!1,section:"Observations"};default:return{pinned:!1}}}function Tn(a){let e=[];for(let t of a.matchAll(ja)){let s=(t[2]??"").trim();if(!s)continue;let n=zc(t[1]);e.push({text:s,pinned:n.pinned,section:n.section})}return e}function Jt(a){return a.replace(ja,"").replace(/[ \t]+\n/g,` +`).replace(/\n{3,}/g,` -`))s.trim()&&e.createEl("p",{text:s});new Js.Setting(e).addButton(s=>s.setButtonText("Cancel").onClick(()=>this.close())).addButton(s=>{s.setButtonText(this.opts.confirmText??"Confirm").onClick(()=>{this.close(),this.opts.onConfirm()}),this.opts.danger&&s.setWarning()})}onClose(){this.contentEl.empty()}};var Qs=[{value:"opus",label:"opus \u2014 latest Opus"},{value:"sonnet",label:"sonnet \u2014 latest Sonnet"},{value:"haiku",label:"haiku \u2014 latest Haiku"},{value:"opusplan",label:"opusplan \u2014 plan-mode Opus"}],Zs=[{value:"gpt-5.5",label:"gpt-5.5 \u2014 frontier"},{value:"gpt-5.4",label:"gpt-5.4"},{value:"gpt-5.4-mini",label:"gpt-5.4-mini \u2014 fast"},{value:"gpt-5.3-codex",label:"gpt-5.3-codex"}],Xs="__custom__";function Lr(r){let t=(r??"").trim().toLowerCase();return t==="codex"||t==="openai-codex"}function Fr(r){return Lr(r)?Zs:Qs}function xl(r,t){let e=r.trim();return!e||e==="default"||e==="subscription"?"inherit":Fr(t).some(s=>s.value===e)?"alias":"custom"}function Rt(r,t){r.empty(),r.addClass("af-model-picker");let e=Lr(t.adapter),s=Fr(t.adapter),n=xl(t.value,t.adapter),a=r.createEl("select",{cls:"af-form-select af-mp-select"}),i=t.allowInherit?t.inheritPlaceholder??"Inherit from agent":e?"Default (let Codex pick)":"Default (let Claude Code pick)";a.createEl("option",{text:i,attr:{value:""}});let o=a.createEl("optgroup",{attr:{label:e?"Codex models":"Aliases (any backend)"}});for(let l of s)o.createEl("option",{text:l.label,attr:{value:l.value}});a.createEl("option",{text:"Custom\u2026",attr:{value:Xs}});let c=r.createEl("input",{cls:"af-form-input af-mp-custom-input",attr:{type:"text",placeholder:e?"e.g. gpt-5.5 \xB7 gpt-5.4-mini":"e.g. claude-opus-4-7 \xB7 us.anthropic.claude-opus-4-7 \xB7 claude-opus-4-7@20251101",spellcheck:"false"}});n==="inherit"?(a.value="",c.value="",c.setCssStyles({display:"none"})):n==="alias"?(a.value=t.value.trim(),c.value="",c.setCssStyles({display:"none"})):(a.value=Xs,c.value=t.value.trim(),c.setCssStyles({display:""})),a.addEventListener("change",()=>{a.value===Xs?(c.setCssStyles({display:""}),c.focus(),t.onChange(c.value.trim())):(c.setCssStyles({display:"none"}),t.onChange(a.value))}),c.addEventListener("input",()=>{a.value===Xs&&t.onChange(c.value.trim())})}var fe=require("obsidian");function Sl(){let r=new Date,t=r.getFullYear(),e=String(r.getMonth()+1).padStart(2,"0"),s=String(r.getDate()).padStart(2,"0");return`${t}-${e}-${s}`}var Cl="0 3 * * *",Tl="0 9 * * 0";function na(){return{scopeSlug:"",scopeRoot:"",inboxPath:"_sources/inbox",archivePath:"_sources/archive",topicsRoot:"_topics",indexPath:"index.md",logPath:"log.md",watchedFolders:[],excludePatterns:[],watchedSince:Sl(),heartbeatChannel:"",fileSubstantiveAnswers:!0,obsidianUrlScheme:!0,maxTokensPerIngest:6e4,maxTokensPerRefresh:3e4,indexSplitThreshold:100,dedupSimilarityThreshold:.82,summaryStaleDays:30,failedPath:"_sources/failed",stateFile:".wiki-keeper-state.json"}}var Br=na();function aa(r){let t=oe(r).trim();return t?`wiki-keeper-${t}`:"wiki-keeper"}function _l(r,t){let e=t||"the whole vault",s={name:r,description:`Cultivates ${e} as a self-maintaining wiki. Ingests new sources, extracts durable claims from watched folders, answers questions with citations, and periodically lints.`,avatar:"library",enabled:!0,skills:["wiki-ingest","wiki-query","wiki-lint","wiki-refresh"],tags:t?["wiki-keeper",`scope:${oe(t)}`]:["wiki-keeper"]},n=`You are the Wiki Keeper for the \`${e}\` scope. Maintain it as a persistent, interlinked knowledge base (Karpathy's "LLM wiki" pattern). +`).trim()}function hi(a){let e=a.replace(ja,""),t=e.match(/\[REMEMBER(?::[a-zA-Z]+)?\][\s\S]*$/i);if(t&&t.index!==void 0)return e.slice(0,t.index);let s=e.match(/\[(?:R(?:E(?:M(?:E(?:M(?:B(?:E(?:R(?::[a-zA-Z]*)?)?)?)?)?)?)?)?)?$/i);return s&&s.index!==void 0?e.slice(0,s.index):e}var Gc={Preferences:"Preferences",Procedures:"Procedures",Observations:"Observations",Recent:"Recent (uncurated)"};function Vc(a){let e=a.trim().toLowerCase();return e.startsWith("preference")?"Preferences":e.startsWith("procedure")?"Procedures":e.startsWith("observation")?"Observations":e.startsWith("recent")?"Recent":"Observations"}function Bs(a){return Math.ceil(a.length/4)}var li=500;function pi(a){let e=a.replace(/\s+/g," ").trim();return e.length<=li?e:`${e.slice(0,li-1).trimEnd()}\u2026`}function $a(a){return typeof a=="string"?a:void 0}function Yc(a,e){return typeof a=="number"&&Number.isFinite(a)?a:e}function mi(a,e){return{filePath:a,agent:e,schema:Kt,tokenEstimate:0,sections:[]}}var Kc=/\s*\s*$/,ci=/^\[pin\]\s+/i;function Jc(a){let e=a.match(/^[-*]\s+(.*)$/);if(!e)return null;let t=(e[1]??"").trim();if(!t)return null;let s,n,r=t.match(Kc);if(r){t=t.slice(0,r.index).trim();let o=(r[1]??"").trim(),l=o.match(/^src:(\S+)(?:\s+(.+))?$/);l?(s=l[1],n=l[2]?.trim()||void 0):o&&(n=o)}let i=!1;return ci.test(t)&&(i=!0,t=t.replace(ci,"").trim()),t?{text:t,source:s,date:n,pinned:i}:null}function fi(a){let e=a.pinned?"[pin] ":"",t="";if(a.source||a.date){let s=[];a.source&&s.push(`src:${a.source}`),a.date&&s.push(a.date),t=` `}return`- ${e}${a.text}${t}`}function at(a){return Xc(a).map(t=>{let s=t.entries.map(fi).join(` +`);return`## ${Gc[t.name]} +${s}`}).join(` + +`)}function Xc(a){let e=[];for(let t of di){let s=a.find(n=>n.name===t);s&&s.entries.length>0&&e.push(s)}return e}function gi(a,e,t){let{frontmatter:s,body:n}=W(a),r=_n(n);return{filePath:e,agent:$a(s.agent)??t,schema:Yc(s.schema,Kt),lastUpdated:$a(s.last_updated),lastReflection:$a(s.last_reflection),tokenEstimate:Bs(at(r)),sections:r}}function _n(a){let e=new Map,t=(r,i)=>{let o=e.get(r)??[];o.push(i),e.set(r,o)},s="Observations";for(let r of a.split(` +`)){let i=r.match(/^#{1,6}\s+(.+?)\s*$/);if(i){s=Vc(i[1]??"");continue}let o=Jc(r);o&&t(s,o)}let n=[];for(let r of di){let i=e.get(r);i&&i.length&&n.push({name:r,entries:i})}return n}function yi(a){let e=at(a.sections),t={agent:a.agent,schema:a.schema||Kt,last_updated:a.lastUpdated??"",token_estimate:Bs(e)};return a.lastReflection&&(t.last_reflection=a.lastReflection),U(t,e||"## Observations")}function vi(a,e,t,s){if(e.length===0)return a;let n=a.sections.map(o=>({name:o.name,entries:[...o.entries]})),r=n.find(o=>o.name===t);return r||(r={name:t,entries:[]},n.push(r)),r.entries.push(...e),{...a,sections:n,lastUpdated:s??a.lastUpdated,tokenEstimate:Bs(at(n))}}var Qc=["Recent","Observations","Procedures"];function wi(a,e){if(a.tokenEstimate<=e)return{wm:a,spilled:[]};let t=a.sections.map(o=>({name:o.name,entries:[...o.entries]})),s=[],n=at(t).length,r=()=>Math.ceil(n/4)>e;for(let o of Qc){if(!r())break;let l=t.find(c=>c.name===o);if(l)for(;l.entries.length>0&&r();){let c=l.entries.findIndex(u=>!u.pinned);if(c===-1)break;let d=l.entries.splice(c,1)[0];d&&(s.push(d),n-=fi(d).length+1)}}let i=t.filter(o=>o.entries.length>0);return{wm:{...a,sections:i,tokenEstimate:Bs(at(i))},spilled:s}}function Ha(a,e,t,s){let n=_n(a);return{filePath:e,agent:t,schema:Kt,lastUpdated:s,tokenEstimate:Bs(at(n)),sections:n}}function bi(a,e){let t=(a?.sections??[]).flatMap(i=>i.entries).filter(i=>i.pinned);if(t.length===0||e.some(i=>i.entries.some(o=>o.pinned)))return e;let n=e.map(i=>({name:i.name,entries:[...i.entries]})),r=n.find(i=>i.name==="Preferences");return r||(r={name:"Preferences",entries:[]},n.unshift(r)),r.entries.unshift(...t),n}var Zc="When you learn a durable fact about the user, their preferences, or how to do your work better, save it to memory. Prefer the `remember` tool \u2014 call remember(fact, pin?, section?); it is the reliable way to record a memory. If that tool is not available, fall back to writing [REMEMBER] [/REMEMBER] in your reply (use [REMEMBER:pin] for standing preferences and hard constraints). Record only durable, reusable facts \u2014 not transient task details. These notes persist into your future runs.";function ki(a,e){if(!a.memory)return"";let s=(e?at(e.sections).trim():"")||"Nothing yet \u2014 this is a fresh agent.";return`## Memory +${Zc} + +### What you've learned so far +${s}`}var An=class{constructor(e,t){this.app=e;this.getMemoryRoot=t;this.vault=e.vault}migratingMemory=new Map;vault;getMemoryPath(e){return(0,ve.normalizePath)(`${this.getMemoryRoot()}/${z(e)}.md`)}getMemoryDir(e){return(0,ve.normalizePath)(`${this.getMemoryRoot()}/${z(e)}`)}getWorkingMemoryPath(e){return(0,ve.normalizePath)(`${this.getMemoryDir(e)}/working.md`)}getRawMemoryPath(e,t){let s=t.slice(0,10);return(0,ve.normalizePath)(`${this.getMemoryDir(e)}/raw/${s}.md`)}async readWorkingMemory(e){let t=this.getWorkingMemoryPath(e),s=this.vault.getAbstractFileByPath(t);if(s instanceof ve.TFile){let i=await this.vault.cachedRead(s);return this.hasIncompatibleSchema(i,t)?null:gi(i,t,e)}let n=this.getMemoryPath(e),r=this.vault.getAbstractFileByPath(n);if(r instanceof ve.TFile){let i=await this.vault.cachedRead(r),{body:o}=W(i);return Ha(o,t,e)}return null}async writeWorkingMemory(e,t){let s=this.getWorkingMemoryPath(e);await fe(this.vault,this.getMemoryDir(e));let n=yi(t),r=this.vault.getAbstractFileByPath(s);if(r instanceof ve.TFile){if(this.hasIncompatibleSchema(await this.vault.cachedRead(r),s))return;await this.vault.modify(r,n)}else await We(this.vault,s,n);let i=this.getMemoryPath(e);i!==s&&this.vault.getAbstractFileByPath(i)&&await qe(this.app,i)}hasIncompatibleSchema(e,t){let{frontmatter:s}=W(e),n=s.schema;return n===void 0||typeof n=="number"&&Number.isFinite(n)&&n<=Kt?!1:(console.warn(`Agent Fleet: ${t} has unrecognized memory schema ${JSON.stringify(n)} (this plugin supports up to ${Kt}) \u2014 treating working memory as unavailable and leaving the file untouched. Update the plugin to use this memory.`),!0)}async migrateLegacyMemory(e){let t=this.migratingMemory.get(e);if(t)return t;let s=this.getWorkingMemoryPath(e);if(this.vault.getAbstractFileByPath(s))return;let n=this.getMemoryPath(e),r=this.vault.getAbstractFileByPath(n);if(!(r instanceof ve.TFile))return;let i=(async()=>{let o=await this.vault.cachedRead(r),{body:l}=W(o),c=new Date().toISOString(),d=l.split(` +`).map(p=>p.replace(/^\s*[-*]\s+/,"").trim()).filter(p=>p.length>0&&!/^#{1,6}\s/.test(p)),u=[`- ${c} [migrated] Imported ${d.length} legacy memory entr${d.length===1?"y":"ies"} (pre-v2).`,...d.map(p=>`- ${c} [migrated] ${p}`)];await this.appendRawMemory(e,u,c);let h=Ha(l,s,e,c);await this.writeWorkingMemory(e,h)})();this.migratingMemory.set(e,i);try{await i}finally{this.migratingMemory.delete(e)}}getPendingDir(e){return(0,ve.normalizePath)(`${this.getMemoryDir(e)}/pending`)}getPendingDirAbsolutePath(e){let t=this.vault.adapter;return t instanceof ve.FileSystemAdapter?(0,xi.join)(t.getBasePath(),this.getPendingDir(e)):null}async ensureMemoryDir(e){await fe(this.vault,this.getMemoryDir(e))}async readAndClearPending(e){let t=this.vault.adapter,s=this.getPendingDir(e),n;try{if(!await t.exists(s))return[];n=(await t.list(s)).files}catch{return[]}let r=[];for(let i of n)if(i.endsWith(".json"))try{let o=await t.read(i);await t.remove(i);for(let l of o.split(` +`))l.trim().length>0&&r.push(l)}catch(o){console.warn(`Agent Fleet: skipping pending capture file ${i} (retried next drain)`,o)}return r}async readRecentRaw(e,t=2){let s=(0,ve.normalizePath)(`${this.getMemoryDir(e)}/raw`),n=this.vault.getAbstractFileByPath(s);if(!(n instanceof ve.TFolder))return"";let r=n.children.filter(o=>o instanceof ve.TFile&&o.extension==="md").sort((o,l)=>l.name.localeCompare(o.name)).slice(0,t),i=[];for(let o of r.reverse())i.push(`### ${o.basename} +${await this.vault.cachedRead(o)}`);return i.join(` + +`)}getCandidatesPath(e){return(0,ve.normalizePath)(`${this.getMemoryDir(e)}/candidates.json`)}async readCandidates(e){let t=this.getCandidatesPath(e),s=this.vault.getAbstractFileByPath(t);if(!(s instanceof ve.TFile))return[];try{let n=JSON.parse(await this.vault.cachedRead(s));return Array.isArray(n)?n:[]}catch(n){return console.warn(`Agent Fleet: invalid JSON in ${t} \u2014 treating skill-candidate ledger as empty`,n),[]}}async writeCandidates(e,t){let s=this.getCandidatesPath(e);await fe(this.vault,this.getMemoryDir(e));let n=JSON.stringify(t,null,2),r=this.vault.getAbstractFileByPath(s);r instanceof ve.TFile?await this.vault.modify(r,n):await We(this.vault,s,n)}async appendRawMemory(e,t,s){if(t.length===0)return;let n=this.getRawMemoryPath(e,s);await fe(this.vault,n.replace(/\/[^/]+$/,""));let r=t.map(o=>o.trimEnd()).join(` +`),i=this.vault.getAbstractFileByPath(n);if(i instanceof ve.TFile){let o=await this.vault.cachedRead(i);await this.vault.modify(i,`${o.trimEnd()} +${r} +`)}else await We(this.vault,n,`${r} +`)}async getMemory(e){let t=await this.readWorkingMemory(e);return t?{filePath:t.filePath,agent:t.agent,lastUpdated:t.lastUpdated,body:at(t.sections)}:null}async appendMemory(e,t){if(t.length===0)return;let s=this.getMemoryPath(e),n=this.vault.getAbstractFileByPath(s),r=new Date().toISOString(),i=t.map(o=>`- ${o.trim()}`).join(` +`);if(n instanceof ve.TFile){let l=`${(await this.getMemory(e))?.body.trim()||"## Learned Context"} + +${i}`.trim();await this.vault.modify(n,U({agent:e,last_updated:r},l));return}await We(this.vault,s,U({agent:e,last_updated:r},`## Learned Context + +${i}`))}};var Wa=require("obsidian");var Pn=class{constructor(e,t){this.app=e;this.deps=t;this.vault=e.vault}vault;parseMcpServerFile(e,t){let{frontmatter:s,body:n}=W(t),r=C(s.name);if(!r)return this.deps.reportIssue(e,"MCP server requires string field `name`."),null;let i=(C(s.transport)??C(s.type)??"").toLowerCase(),o=["stdio","http","sse"];if(!o.includes(i))return this.deps.reportIssue(e,`MCP server \`${r}\` requires \`transport\` to be one of: ${o.join(", ")}.`),null;let l=i;if(l==="stdio"){if(!C(s.command))return this.deps.reportIssue(e,`MCP server \`${r}\` (stdio) requires a \`command\`.`),null}else if(!C(s.url))return this.deps.reportIssue(e,`MCP server \`${r}\` (${l}) requires a \`url\`.`),null;let c=(C(s.auth)??"").toLowerCase(),d=c==="bearer"||c==="oauth"||c==="none"?c:void 0,u;if(Tt(s.oauth)){let h=s.oauth;u={clientId:C(h.client_id)??C(h.clientId),resource:C(h.resource),scopes:Q(h.scopes)}}return{name:r,filePath:e,type:l,enabled:Ie(s.enabled,!0),description:C(s.description)??(n.trim()||void 0),source:C(s.source)==="imported"?"imported":"manual",command:C(s.command),args:Q(s.args),env:Na(s.env),envSecretKeys:Q(s.env_secret_keys),url:C(s.url),headers:Na(s.headers),auth:d,oauth:u,status:"disconnected",scope:"user",tools:[],toolDetails:[]}}mcpServerFrontmatter(e){let t={name:e.name,transport:e.type,enabled:e.enabled};return e.source&&(t.source=e.source),e.type==="stdio"?(e.command&&(t.command=e.command),e.args&&e.args.length>0&&(t.args=e.args),e.env&&Object.keys(e.env).length>0&&(t.env=e.env),e.envSecretKeys&&e.envSecretKeys.length>0&&(t.env_secret_keys=e.envSecretKeys)):(e.url&&(t.url=e.url),e.headers&&Object.keys(e.headers).length>0&&(t.headers=e.headers),e.auth&&(t.auth=e.auth),e.oauth&&(e.oauth.clientId||e.oauth.resource||(e.oauth.scopes?.length??0)>0)&&(t.oauth={client_id:e.oauth.clientId||void 0,resource:e.oauth.resource||void 0,scopes:e.oauth.scopes&&e.oauth.scopes.length>0?e.oauth.scopes:void 0})),t}async saveMcpServer(e,t=""){let s=this.mcpServerFrontmatter(e),n=U(s,t.trim()),r=e.filePath?this.vault.getAbstractFileByPath(e.filePath):null;if(r instanceof Wa.TFile)return await this.vault.modify(r,n),r.path;let i=await _t(this.vault,this.deps.getMcpDir(),z(e.name));return await fe(this.vault,this.deps.getMcpDir()),await this.vault.create(i,n),i}async setMcpServerEnabled(e,t){let s=this.deps.getServerByName(e);if(!s?.filePath)return;let n=this.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof Wa.TFile))return;let r=await this.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);i.enabled=t,await this.vault.modify(n,U(i,o))}async deleteMcpServer(e){let t=this.deps.getServerByName(e);t?.filePath&&await qe(this.app,t.filePath)}};var dt=require("obsidian");var En=class{constructor(e,t){this.app=e;this.deps=t;this.vault=e.vault}vault;getProposalsDir(){return(0,dt.normalizePath)(`${this.deps.getFleetRoot()}/proposals`)}async listProposals(){let e=this.vault.getAbstractFileByPath(this.getProposalsDir());if(!(e instanceof dt.TFolder))return[];let t=[];for(let s of e.children){if(!(s instanceof dt.TFile)||s.extension!=="md")continue;let n=await this.readProposal(s.basename);n&&t.push(n)}return t.sort((s,n)=>n.created.localeCompare(s.created)),t}async readProposal(e){let t=(0,dt.normalizePath)(`${this.getProposalsDir()}/${e}.md`),s=this.vault.getAbstractFileByPath(t);if(!(s instanceof dt.TFile))return null;let{frontmatter:n,body:r}=W(await this.vault.cachedRead(s)),i=n.type==="skill_modify"?"skill_modify":"skill_create",o=n.status==="accepted"||n.status==="rejected"?n.status:"pending";return{id:e,type:i,agent:C(n.agent)??"",status:o,created:C(n.created)??"",targetSkill:C(n.target_skill),candidate:C(n.candidate),evidence:Q(n.evidence),rationale:C(n.rationale)??"",body:r}}async writeProposal(e){await fe(this.vault,this.getProposalsDir());let t=(0,dt.normalizePath)(`${this.getProposalsDir()}/${e.id}.md`),s={id:e.id,type:e.type,agent:e.agent,status:e.status,created:e.created,target_skill:e.targetSkill||void 0,candidate:e.candidate||void 0,evidence:e.evidence.length?e.evidence:void 0,rationale:e.rationale||void 0},n=U(s,e.body||""),r=this.vault.getAbstractFileByPath(t);r instanceof dt.TFile?await this.vault.modify(r,n):await We(this.vault,t,n)}async setProposalStatus(e,t){let s=await this.readProposal(e);s&&(s.status=t,await this.writeProposal(s))}async applyProposal(e){if(e.type==="skill_create"){let t=e.targetSkill||`learned-${z(e.rationale).slice(0,24)||"skill"}`,s=await _t(this.vault,this.deps.getSkillsDir(),z(t)),r={name:s.split("/").pop()?.replace(/\.md$/,"")||z(t),description:e.rationale||`Auto-proposed from recurring pattern for ${e.agent}.`,tags:["proposed"]};return await this.vault.create(s,U(r,e.body||"Skill instructions go here.")),s}if(e.targetSkill){let t=this.deps.getSkillByName(e.targetSkill);if(t){let s=this.vault.getAbstractFileByPath(t.filePath);if(s instanceof dt.TFile){let n=await this.vault.cachedRead(s),r=` + +## Proposed update (${new Date().toISOString().slice(0,10)}) +${e.body}`;return await this.vault.modify(s,`${n.trimEnd()}${r} +`),t.filePath}}}return null}async deleteProposal(e){await qe(this.app,(0,dt.normalizePath)(`${this.getProposalsDir()}/${e}.md`))}};var bt=require("obsidian");var Pt=require("child_process"),Si=require("fs"),Rn=require("os"),Xt=require("path");function qa(){return(0,Rn.homedir)()}function Ci(){if(process.platform==="darwin")return"/bin/zsh";for(let a of["/bin/bash","/bin/zsh","/bin/sh"])if((0,Si.existsSync)(a))return a;return"/bin/sh"}function ed(a){return`'${a.replace(/'/g,"'\\''")}'`}function wt(a,e,t){let s={cwd:t?.cwd,env:t?.env};if(process.platform==="win32")return(0,Pt.spawn)(a,e,s);let n=Ci(),r=[a,...e].map(ed).join(" ");return(0,Pt.spawn)(n,["-l","-c",r],s)}function Ti(a,e){let t={cwd:e?.cwd,env:e?.env,stdio:["pipe","pipe","pipe"]};if(process.platform==="win32")return(0,Pt.spawn)(a,[],{...t,shell:!0});let s=Ci();return(0,Pt.spawn)(s,["-l","-c",a],t)}function _i(a){try{require("electron").shell.openExternal(a)}catch{switch(process.platform){case"darwin":(0,Pt.spawn)("open",[a],{stdio:"ignore"});break;case"win32":(0,Pt.spawn)("cmd.exe",["/c","start","",a.replace(/&/g,"^&")],{stdio:"ignore"});break;default:(0,Pt.spawn)("xdg-open",[a],{stdio:"ignore"});break}}}function re(a){return a.split(/\r?\n/)}function Ai(a){let e=(0,Rn.homedir)();if(process.platform==="win32")return[a,(0,Xt.join)(process.env.APPDATA??"","Claude","claude.exe"),(0,Xt.join)(process.env.LOCALAPPDATA??"","Claude","claude.exe"),(0,Xt.join)(e,".local","bin","claude.exe"),"claude.exe","claude"].filter(s=>!!s&&Dn(s));let t=[a,(0,Xt.join)(e,".local","bin","claude")];return process.platform==="darwin"&&t.push("/opt/homebrew/bin/claude"),t.push("/usr/local/bin/claude","/usr/bin/claude","claude"),t.filter(s=>!!s&&Dn(s))}function za(a){let e=(0,Rn.homedir)();if(process.platform==="win32")return[a,(0,Xt.join)(e,".local","bin","codex.exe"),"codex.exe","codex"].filter(s=>!!s&&Dn(s));let t=[a,(0,Xt.join)(e,".local","bin","codex")];return process.platform==="darwin"&&t.push("/opt/homebrew/bin/codex"),t.push("/usr/local/bin/codex","/usr/bin/codex","codex"),t.filter(s=>!!s&&Dn(s))}function Dn(a){return!a||/[\n\r\0]/.test(a)?!1:a.startsWith("/")?/^[\w/.@+-]+$/.test(a):a.startsWith("~")?/^~[\w/.@+-]*$/.test(a):/^[a-zA-Z]:[\\/]/.test(a)?/^[a-zA-Z]:[\\/][\w\\/. @+-]+$/.test(a):a.startsWith("\\\\")?/^\\\\[\w\\/. @+-]+$/.test(a):!a.includes("/")&&!a.includes("\\")?/^[\w.@+-]+$/.test(a):!1}function Ga(a){return!!(a.includes("/")||a.includes("\\"))}var In=class{constructor(e,t){this.getRunsRoot=t;this.vault=e.vault}runLogCache=new Map;vault;async listRecentRuns(e=50){let t=this.vault.getAbstractFileByPath(this.getRunsRoot());if(!(t instanceof bt.TFolder))return[];let s=[];ps(t,s),s.sort((i,o)=>o.path.localeCompare(i.path));let n=s.slice(0,e),r=[];for(let i of n){let o=await this.readRunLog(i);o&&r.push(o)}return r.sort((i,o)=>o.started.localeCompare(i.started))}async listRunsSince(e){let t=this.vault.getAbstractFileByPath(this.getRunsRoot());if(!(t instanceof bt.TFolder))return[];let s=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`,n=[];for(let i of t.children)i instanceof bt.TFolder&&(i.nameo.started.localeCompare(i.started))}async readRunLog(e){let t=this.runLogCache.get(e.path);if(t&&t.mtime===e.stat.mtime&&t.size===e.stat.size)return t.run;let s=await this.vault.cachedRead(e),{frontmatter:n,body:r}=W(s),i=r.match(/## Prompt\n([\s\S]*?)(?:\n## Result\n|\n## Output\n|$)/),o=r.match(/## Result\n([\s\S]*?)(?:\n## Output\n|$)/),l=r.match(/## Output\n([\s\S]*?)(?:\n## Tools Used\n|$)/),c=r.match(/## Tools Used\n([\s\S]*?)(?:\n## STDERR\n|$)/),d={filePath:e.path,runId:C(n.run_id)??e.basename,agent:C(n.agent)??"unknown",task:C(n.task)??"unknown",status:C(n.status)??"failure",started:C(n.started)??new Date(e.stat.ctime).toISOString(),completed:C(n.completed),durationSeconds:Pe(n.duration_seconds,0),tokensUsed:typeof n.tokens_used=="number"?n.tokens_used:void 0,costUsd:typeof n.cost_usd=="number"?n.cost_usd:void 0,model:C(n.model)??ft.defaultModel,modelSource:(()=>{let u=C(n.model_source);if(u==="task"||u==="agent"||u==="settings"||u==="cli-default")return u})(),concreteModel:C(n.resolved_concrete_model),exitCode:typeof n.exit_code=="number"?n.exit_code:null,tags:Q(n.tags),prompt:i?.[1]?.trim()??"",output:l?.[1]?.trim()??"",finalResult:o?.[1]?.trim()||void 0,toolsUsed:c?.[1]?re(c[1]).map(u=>u.replace(/^- /,"").trim()).filter(Boolean):[],approvals:this.parseApprovals(n.approvals)};return this.runLogCache.size>=5e3&&this.runLogCache.clear(),this.runLogCache.set(e.path,{mtime:e.stat.mtime,size:e.stat.size,run:d}),d}async writeRunLog(e){let t=new Date(e.started),s=(0,bt.normalizePath)(`${this.getRunsRoot()}/${t.toISOString().slice(0,10)}`);await fe(this.vault,s);let n=`${t.toISOString().slice(11,19).replace(/:/g,"")}-${z(e.agent)}-${z(e.task)}.md`,r=(0,bt.normalizePath)(`${s}/${n}`),i=U({run_id:e.runId,agent:e.agent,task:e.task,status:e.status,started:e.started,completed:e.completed,duration_seconds:e.durationSeconds,tokens_used:e.tokensUsed,cost_usd:e.costUsd,model:e.model,model_source:e.modelSource,resolved_concrete_model:e.concreteModel,exit_code:e.exitCode,tags:e.tags,approvals:e.approvals},["## Prompt","",e.prompt.trim(),"",...e.finalResult&&e.finalResult.trim()?["## Result","",e.finalResult.trim(),""]:[],"## Output","",e.output.trim()||"(no output)","","## Tools Used","",...e.toolsUsed.length>0?e.toolsUsed.map(l=>`- ${l}`):["- none"],...e.stderr?["","## STDERR","",e.stderr.trim()]:[]].join(` +`)),o=this.vault.getAbstractFileByPath(r);return o instanceof bt.TFile?await this.vault.modify(o,i):await this.vault.create(r,i),r}async setApprovalDecision(e,t,s){let n=this.vault.getAbstractFileByPath(e);if(!(n instanceof bt.TFile))return;let r=await this.vault.cachedRead(n),{frontmatter:i,body:o}=W(r),l=(this.parseApprovals(i.approvals)??[]).map(c=>c.tool===t?{...c,status:s,resolvedAt:new Date().toISOString()}:c);await this.vault.modify(n,U({...i,approvals:l},o))}parseApprovals(e){if(Array.isArray(e))return e.flatMap(t=>{if(!Tt(t)||!C(t.tool))return[];let s=C(t.tool);return s?[{tool:s,command:C(t.command),reason:C(t.reason),status:C(t.status)??"pending",resolvedAt:C(t.resolvedAt),note:C(t.note)}]:[]})}};var Va=require("obsidian");function Pi(a){let e=new Map;for(let s of a){if(typeof s.costUsd!="number")continue;let n=e.get(s.agent);n?n.push(s):e.set(s.agent,[s])}let t=0;for(let s of e.values()){s.sort((r,i)=>r.ts.localeCompare(i.ts));let n=0;for(let r of s){let i=r.costUsd,o=i>=n?i-n:i;n=i,o!==i&&t++,r.costUsd=o}}return t}var Mn=class{constructor(e,t){this.getUsageDir=t;this.vault=e.vault}vault;usageLedgerPath(e){return(0,Va.normalizePath)(`${this.getUsageDir()}/${e.slice(0,10)}.jsonl`)}async appendUsage(e){await fe(this.vault,this.getUsageDir());let t=this.usageLedgerPath(e.ts),s=`${JSON.stringify(e)} +`,n=this.vault.adapter;await n.exists(t)?await n.append(t,s):await n.write(t,s)}async readUsageSince(e){let t=this.getUsageDir(),s=this.vault.adapter;if(!await s.exists(t))return[];let n=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`,r=[],i=await s.list(t);for(let o of i.files){if(!o.endsWith(".jsonl")||(o.split("/").pop()??"").replace(/\.jsonl$/,"")0)for(let{path:l,records:c}of r){if(c.length===0)continue;let d=c.map(u=>JSON.stringify(u)).join(` +`)+` +`;await t.write(l,d)}return await t.write(s,`migrated ${i.length} rows; corrected ${o} +`),{files:r.length,rows:i.length,changed:o}}catch(n){return console.error("Agent Fleet: usage-ledger cost migration failed (ledger left untouched)",n),null}}};function Ei(a){let e=0;for(let t=0;tthis.getFleetRoot(),getSubfolder:s=>this.getSubfolder(s),parseMcpServerFile:(s,n)=>this.mcp.parseMcpServerFile(s,n)}),this.mutations=new Sn(e,{store:this.entities,getSubfolder:s=>this.getSubfolder(s),getMemoryPath:s=>this.getMemoryPath(s),getMemoryDir:s=>this.getMemoryDir(s)}),this.runLogs=new In(e,()=>this.getRunsRoot()),this.usage=new Mn(e,()=>this.getSubfolder("usage")),this.memory=new An(e,()=>this.getSubfolder("memory")),this.conversations=new xn(e,s=>this.getMemoryPath(s)),this.proposals=new En(e,{getFleetRoot:()=>this.getFleetRoot(),getSkillsDir:()=>this.getSubfolder("skills"),getSkillByName:s=>this.getSkillByName(s)}),this.mcp=new Pn(e,{getMcpDir:()=>this.getSubfolder("mcp"),getServerByName:s=>this.getMcpServerByName(s),reportIssue:(s,n)=>this.entities.setIssue(s,n)})}vault;entities;mutations;runLogs;usage;conversations;proposals;mcp;memory;setChannelCredentialGetter(e){this.entities.setChannelCredentialGetter(e)}getVaultBasePath(){let e=this.vault.adapter;return e instanceof Et.FileSystemAdapter?e.getBasePath():void 0}getFleetRoot(){return(0,Et.normalizePath)(this.settings.fleetFolder)}getSubfolder(e){return(0,Et.normalizePath)(`${this.getFleetRoot()}/${e}`)}async ensureFleetStructure(){let e=this.getFleetRoot(),t=!this.vault.getAbstractFileByPath(e);await fe(this.vault,e);for(let s of Xr)await fe(this.vault,this.getSubfolder(s));return t}async ensureSamples(){let e=this.getFleetRoot();for(let t of Ls){let s=(0,Et.normalizePath)(`${e}/${t.path}`),n=s.substring(0,s.lastIndexOf("/"));await fe(this.vault,n),await We(this.vault,s,t.content)}}async updateDefaults(e){let t=this.getFleetRoot(),s={...e};for(let n of Ls){let r=(0,Et.normalizePath)(`${t}/${n.path}`),i=Ei(n.content),o=e[n.path];if(o===i)continue;let l=this.vault.getAbstractFileByPath(r);if(!(l instanceof Et.TFile)){let u=r.substring(0,r.lastIndexOf("/"));await fe(this.vault,u),await We(this.vault,r,n.content),s[n.path]=i;continue}let c=await this.vault.cachedRead(l),d=Ei(c);(!o||d===o)&&(await this.vault.modify(l,n.content),s[n.path]=i)}return s}async loadAll(){return this.entities.loadAll()}async loadFile(e){return this.entities.loadFile(e)}removeFile(e){this.entities.removeFile(e)}getSnapshot(){return this.entities.getSnapshot()}getAgentByName(e){return this.entities.getAgentByName(e)}getSkillByName(e){return this.entities.getSkillByName(e)}getTaskById(e){return this.entities.getTaskById(e)}getTasksForAgent(e){return this.entities.getTasksForAgent(e)}getChannelByName(e){return this.entities.getChannelByName(e)}getChannelsForAgent(e){return this.entities.getChannelsForAgent(e)}getMcpServers(){return this.entities.getMcpServers()}getMcpServerByName(e){return this.entities.getMcpServerByName(e)}getRunsRoot(){return this.getSubfolder("runs")}getMemoryPath(e){return this.memory.getMemoryPath(e)}getMemoryDir(e){return this.memory.getMemoryDir(e)}getWorkingMemoryPath(e){return this.memory.getWorkingMemoryPath(e)}getRawMemoryPath(e,t){return this.memory.getRawMemoryPath(e,t)}async readWorkingMemory(e){return this.memory.readWorkingMemory(e)}async writeWorkingMemory(e,t){return this.memory.writeWorkingMemory(e,t)}async migrateLegacyMemory(e){return this.memory.migrateLegacyMemory(e)}async migrateAllLegacyMemory(){for(let e of this.getSnapshot().agents)if(e.memory)try{await this.memory.migrateLegacyMemory(e.name)}catch(t){console.warn(`Agent Fleet: legacy memory migration failed for "${e.name}"`,t)}}getPendingDir(e){return this.memory.getPendingDir(e)}getPendingDirAbsolutePath(e){return this.memory.getPendingDirAbsolutePath(e)}async ensureMemoryDir(e){return this.memory.ensureMemoryDir(e)}async readAndClearPending(e){return this.memory.readAndClearPending(e)}async readRecentRaw(e,t=2){return this.memory.readRecentRaw(e,t)}getCandidatesPath(e){return this.memory.getCandidatesPath(e)}async readCandidates(e){return this.memory.readCandidates(e)}async writeCandidates(e,t){return this.memory.writeCandidates(e,t)}getProposalsDir(){return this.proposals.getProposalsDir()}async listProposals(){return this.proposals.listProposals()}async readProposal(e){return this.proposals.readProposal(e)}async writeProposal(e){return this.proposals.writeProposal(e)}async setProposalStatus(e,t){return this.proposals.setProposalStatus(e,t)}async applyProposal(e){return this.proposals.applyProposal(e)}async deleteProposal(e){return this.proposals.deleteProposal(e)}async appendRawMemory(e,t,s){return this.memory.appendRawMemory(e,t,s)}async listConversations(e){return this.conversations.listConversations(e)}async createConversation(e,t,s){return this.conversations.createConversation(e,t,s)}async renameConversation(e,t,s){return this.conversations.renameConversation(e,t,s)}async deleteConversation(e,t){return this.conversations.deleteConversation(e,t)}async getMemory(e){return this.memory.getMemory(e)}async appendMemory(e,t){return this.memory.appendMemory(e,t)}async listRecentRuns(e=50){return this.runLogs.listRecentRuns(e)}async listRunsSince(e){return this.runLogs.listRunsSince(e)}async appendUsage(e){return this.usage.appendUsage(e)}async readUsageSince(e){return this.usage.readUsageSince(e)}async migrateUsageLedgerCosts(){return this.usage.migrateUsageLedgerCosts()}async readRunLog(e){return this.runLogs.readRunLog(e)}async writeRunLog(e){return this.runLogs.writeRunLog(e)}async setApprovalDecision(e,t,s){return this.runLogs.setApprovalDecision(e,t,s)}async updateTaskRunMetadata(e,t){return this.mutations.updateTaskRunMetadata(e,t)}async createAgentTemplate(e){return this.mutations.createAgentTemplate(e)}async createAgentFolder(e){return this.mutations.createAgentFolder(e)}async createSkillTemplate(e){return this.mutations.createSkillTemplate(e)}async createSkillFolder(e){return this.mutations.createSkillFolder(e)}async updateAgent(e,t){return this.mutations.updateAgent(e,t)}async updateTask(e,t){return this.mutations.updateTask(e,t)}async updateSkill(e,t){return this.mutations.updateSkill(e,t)}async deleteSkill(e){return this.mutations.deleteSkill(e)}async deleteTask(e){return this.mutations.deleteTask(e)}async updateChannel(e,t){return this.mutations.updateChannel(e,t)}async deleteChannel(e){return this.mutations.deleteChannel(e)}async updateHeartbeat(e,t){return this.mutations.updateHeartbeat(e,t)}async deleteAgent(e,t){return this.mutations.deleteAgent(e,t)}async saveMcpServer(e,t=""){return this.mcp.saveMcpServer(e,t)}async setMcpServerEnabled(e,t){return this.mcp.setMcpServerEnabled(e,t)}async deleteMcpServer(e){return this.mcp.deleteMcpServer(e)}async trashFile(e){await qe(this.app,e)}async getAvailablePath(e,t){return _t(this.vault,e,t)}};var Qt=require("obsidian"),Fn=class extends Qt.Modal{constructor(t,s,n){super(t);this.info=s;this.onConfirm=n}deleteTasks=!0;onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("af-confirm-delete-modal");let s=t.createDiv({cls:"af-delete-header"}),n=s.createSpan({cls:"af-delete-header-icon"});(0,Qt.setIcon)(n,"alert-triangle"),s.createEl("h3",{text:`Delete agent "${this.info.agentName}"?`});let r=t.createDiv({cls:"af-delete-summary"});r.createDiv({text:"This action will:"});let i=r.createEl("ul",{cls:"af-delete-impact-list"});i.createEl("li",{text:"Move the agent definition to trash"}),this.info.hasMemory&&i.createEl("li",{text:"Move the agent's memory file to trash"}),this.info.taskCount>0&&i.createEl("li").setText(`${this.info.taskCount} task${this.info.taskCount!==1?"s":""} reference this agent`),this.info.runCount>0&&i.createEl("li",{cls:"af-delete-preserved"}).setText(`${this.info.runCount} run log${this.info.runCount!==1?"s":""} will be preserved`),t.createDiv({cls:"af-delete-note",text:"Files are moved to your system trash and can be recovered."}),this.info.taskCount>0&&new Qt.Setting(t).setName("Also delete associated tasks").setDesc(`Delete ${this.info.taskCount} task${this.info.taskCount!==1?"s":""} that reference this agent`).addToggle(u=>{u.setValue(this.deleteTasks),u.onChange(h=>{this.deleteTasks=h})});let o=t.createDiv({cls:"af-delete-actions"}),l=o.createEl("button",{text:"Cancel"});l.onclick=()=>this.close();let c=o.createEl("button",{cls:"af-delete-confirm-btn",text:"Delete Agent"}),d=c.createSpan({cls:"af-delete-btn-icon"});(0,Qt.setIcon)(d,"trash-2"),c.onclick=()=>{this.onConfirm(this.deleteTasks),this.close()}}};var D=require("obsidian");var Ln=require("obsidian"),ut=class extends Ln.Modal{constructor(t,s){super(t);this.opts=s}onOpen(){let{contentEl:t}=this;t.empty(),t.createEl("h3",{text:this.opts.title});for(let s of this.opts.body.split(` + +`))s.trim()&&t.createEl("p",{text:s});new Ln.Setting(t).addButton(s=>s.setButtonText("Cancel").onClick(()=>this.close())).addButton(s=>{s.setButtonText(this.opts.confirmText??"Confirm").onClick(()=>{this.close(),this.opts.onConfirm()}),this.opts.danger&&s.setWarning()})}onClose(){this.contentEl.empty()}};var Nn=[{value:"opus",label:"opus \u2014 latest Opus"},{value:"sonnet",label:"sonnet \u2014 latest Sonnet"},{value:"haiku",label:"haiku \u2014 latest Haiku"},{value:"opusplan",label:"opusplan \u2014 plan-mode Opus"}],Bn=[{value:"gpt-5.5",label:"gpt-5.5 \u2014 frontier"},{value:"gpt-5.4",label:"gpt-5.4"},{value:"gpt-5.4-mini",label:"gpt-5.4-mini \u2014 fast"},{value:"gpt-5.3-codex",label:"gpt-5.3-codex"}],On="__custom__";function Di(a){let e=(a??"").trim().toLowerCase();return e==="codex"||e==="openai-codex"}function Ri(a){return Di(a)?Bn:Nn}function td(a,e){let t=a.trim();return!t||t==="default"||t==="subscription"?"inherit":Ri(e).some(s=>s.value===t)?"alias":"custom"}function Nt(a,e){a.empty(),a.addClass("af-model-picker");let t=Di(e.adapter),s=Ri(e.adapter),n=td(e.value,e.adapter),r=a.createEl("select",{cls:"af-form-select af-mp-select"}),i=e.allowInherit?e.inheritPlaceholder??"Inherit from agent":t?"Default (let Codex pick)":"Default (let Claude Code pick)";r.createEl("option",{text:i,attr:{value:""}});let o=r.createEl("optgroup",{attr:{label:t?"Codex models":"Aliases (any backend)"}});for(let c of s)o.createEl("option",{text:c.label,attr:{value:c.value}});r.createEl("option",{text:"Custom\u2026",attr:{value:On}});let l=a.createEl("input",{cls:"af-form-input af-mp-custom-input",attr:{type:"text",placeholder:t?"e.g. gpt-5.5 \xB7 gpt-5.4-mini":"e.g. claude-opus-4-7 \xB7 us.anthropic.claude-opus-4-7 \xB7 claude-opus-4-7@20251101",spellcheck:"false"}});n==="inherit"?(r.value="",l.value="",l.setCssStyles({display:"none"})):n==="alias"?(r.value=e.value.trim(),l.value="",l.setCssStyles({display:"none"})):(r.value=On,l.value=e.value.trim(),l.setCssStyles({display:""})),r.addEventListener("change",()=>{r.value===On?(l.setCssStyles({display:""}),l.focus(),e.onChange(l.value.trim())):(l.setCssStyles({display:"none"}),e.onChange(r.value))}),l.addEventListener("input",()=>{r.value===On&&e.onChange(l.value.trim())})}var le=require("obsidian");function sd(){let a=new Date,e=a.getFullYear(),t=String(a.getMonth()+1).padStart(2,"0"),s=String(a.getDate()).padStart(2,"0");return`${e}-${t}-${s}`}var nd="0 3 * * *",ad="0 9 * * 0";function Ya(){return{scopeSlug:"",scopeRoot:"",inboxPath:"_sources/inbox",archivePath:"_sources/archive",topicsRoot:"_topics",indexPath:"index.md",logPath:"log.md",watchedFolders:[],excludePatterns:[],watchedSince:sd(),heartbeatChannel:"",fileSubstantiveAnswers:!0,obsidianUrlScheme:!0,maxTokensPerIngest:6e4,maxTokensPerRefresh:3e4,indexSplitThreshold:100,dedupSimilarityThreshold:.82,summaryStaleDays:30,failedPath:"_sources/failed",stateFile:".wiki-keeper-state.json"}}var Fi=Ya();function Ka(a){let e=z(a).trim();return e?`wiki-keeper-${e}`:"wiki-keeper"}function rd(a,e){let t=e||"the whole vault",s={name:a,description:`Cultivates ${t} as a self-maintaining wiki. Ingests new sources, extracts durable claims from watched folders, answers questions with citations, and periodically lints.`,avatar:"library",enabled:!0,skills:["wiki-ingest","wiki-query","wiki-lint","wiki-refresh"],tags:e?["wiki-keeper",`scope:${z(e)}`]:["wiki-keeper"]},n=`You are the Wiki Keeper for the \`${t}\` scope. Maintain it as a persistent, interlinked knowledge base (Karpathy's "LLM wiki" pattern). ## Scope isolation @@ -11752,7 +11753,7 @@ Use \`memory\` for procedural learning ("user prefers concept pages under sub-fo - Never write outside the scope root. - Never edit \`## Claims\` history (append only). - Never edit \`## Summary\` blocks by hand \u2014 \`wiki-refresh\` owns them. -`;return H(s,n)}function Al(r){let t={model:"opus",adapter:"claude-code",timeout:900,max_retries:1,cwd:"",permission_mode:"acceptEdits",approval_required:["Write"],memory:!0,memory_max_entries:200,wiki_keeper:{scope_root:r.scopeRoot,inbox_path:r.inboxPath,archive_path:r.archivePath,failed_path:r.failedPath,topics_root:r.topicsRoot,index_path:r.indexPath,log_path:r.logPath,watched_folders:r.watchedFolders,exclude_patterns:r.excludePatterns,watched_since:r.watchedSince,file_substantive_answers:r.fileSubstantiveAnswers,obsidian_url_scheme:r.obsidianUrlScheme,max_tokens_per_ingest:r.maxTokensPerIngest,max_tokens_per_refresh:r.maxTokensPerRefresh,index_split_threshold:r.indexSplitThreshold,dedup_similarity_threshold:r.dedupSimilarityThreshold,summary_stale_days:r.summaryStaleDays,state_file:r.stateFile}};return H(t,"")}function El(r){let t={enabled:!0,schedule:Cl,notify:!0};return r.heartbeatChannel&&(t.channel=r.heartbeatChannel),H(t,`Run wiki-ingest in both modes: +`;return U(s,n)}function id(a){let e={model:"opus",adapter:"claude-code",timeout:900,max_retries:1,cwd:"",permission_mode:"acceptEdits",approval_required:["Write"],memory:!0,memory_max_entries:200,wiki_keeper:{scope_root:a.scopeRoot,inbox_path:a.inboxPath,archive_path:a.archivePath,failed_path:a.failedPath,topics_root:a.topicsRoot,index_path:a.indexPath,log_path:a.logPath,watched_folders:a.watchedFolders,exclude_patterns:a.excludePatterns,watched_since:a.watchedSince,file_substantive_answers:a.fileSubstantiveAnswers,obsidian_url_scheme:a.obsidianUrlScheme,max_tokens_per_ingest:a.maxTokensPerIngest,max_tokens_per_refresh:a.maxTokensPerRefresh,index_split_threshold:a.indexSplitThreshold,dedup_similarity_threshold:a.dedupSimilarityThreshold,summary_stale_days:a.summaryStaleDays,state_file:a.stateFile}};return U(e,"")}function od(a){let e={enabled:!0,schedule:nd,notify:!0};return a.heartbeatChannel&&(e.channel=a.heartbeatChannel),U(e,`Run wiki-ingest in both modes: 1. Drain every unprocessed file in the configured inbox (inbox mode). 2. Diff watched folders against the state file; process changed or new files (watched mode). @@ -11760,37 +11761,46 @@ Use \`memory\` for procedural learning ("user prefers concept pages under sub-fo Lint runs on its own schedule via the sibling \`*-lint\` task. Change the schedule by editing this file's \`schedule:\` frontmatter directly, or via the agent editor in the dashboard. -`)}function Ur(r){let e={task_id:`${r}-lint`,agent:r,type:"recurring",schedule:Tl,priority:"low",enabled:!0,created:new Date().toISOString(),run_count:0,catch_up:!1,tags:["wiki-keeper","lint"]};return H(e,"Run the `wiki-lint` skill on the current scope.\n\nWrite the report as a new `## Lint YYYY-MM-DD` section inside the fenced block in log.md.\n")}function ra(r,t){return(0,fe.normalizePath)(`${r}/tasks/${t}-lint.md`)}var Pl="# Wiki Schema\n\n## Page types\n\n- **Entity pages** \u2014 people, orgs, products. Frontmatter: `type: entity`.\n- **Concept pages** \u2014 ideas, techniques, patterns. `type: concept`.\n- **Event pages** \u2014 meetings, releases, decisions. `type: event, date: YYYY-MM-DD`.\n- **Summary pages** (inbox-mode only) \u2014 one per ingested inbox source. `type: summary, source: `.\n- **Synthesis pages** \u2014 filed answers from `wiki-query` substantive responses. `type: synthesis, question: , refreshed: `. Live under `_topics/syntheses/`.\n\nTopic pages of type entity/concept/event SHOULD also carry:\n- `summary_refreshed: ` \u2014 last time `wiki-refresh` regenerated this page's summary block. Empty string on creation.\n- `claims_at_refresh: ` \u2014 how many claims existed at last refresh. Drives the staleness threshold.\n\n## Page anatomy\n\nEvery entity/concept/event page has this structure:\n\n```markdown\n---\ntype: entity\nname: Vendor X\nsummary_refreshed: 2026-04-26\nclaims_at_refresh: 14\n---\n\n\n## Summary\n\n- 3-7 bullet synthesis maintained by wiki-refresh.\n- Forward-links to other topics with [[wikilinks]].\n\n\n## Claims\n\n- 2026-04-18 [meeting]: from [[meetings/2026-04-18-vendor-sync|vendor-sync]]: Vendor X raised prices 15%\n- 2026-04-12 [doc]: from [[summaries/2026-04-12-renewal-brief|renewal-brief]]: contract auto-renews 2026-12-01\n\n## Contradictions\n\n(only present when contradictions exist)\n```\n\nThe fenced summary block is auto-managed by `wiki-refresh`. The `## Claims` section is append-only history written by `wiki-ingest` and `wiki-query` compounding. `## Contradictions` is created on demand. **Anything outside these structures is user-authored** and must be preserved.\n\n## Source-type tags\n\nEvery dated bullet in `## Claims` carries a tag indicating the source type:\n\n- `[doc]` \u2014 PDF, DOCX, XLSX, or other document\n- `[meeting]` \u2014 meeting note or transcript\n- `[email]` \u2014 email forward\n- `[note]` \u2014 markdown daily note or in-vault note\n- `[web]` \u2014 web clipping or URL drop\n- `[synthesis]` \u2014 bullet emitted by `wiki-query` compounding (links to a synthesis page)\n- `[other]` \u2014 fallback\n\nTags help `wiki-query` weight evidence quality and help `wiki-lint` identify drift.\n\n## Naming\n\n- Slug-case filenames: `vendor-x.md`, not `Vendor X.md`.\n- Group by type under `_topics//` when there are >5 pages of a type.\n\n## Links\n\n- Every entity/concept page MUST have \u22651 inbound link from `index.md` (or a sub-MOC) or a sibling.\n- Summary pages MUST forward-link to every entity/concept they mention.\n- Watched-mode extractions append dated entries to topic pages; forward-link to the source file path so readers can find the raw note.\n- Synthesis pages forward-link to every cited topic and earn their place that way (lint exempts them from orphan flagging if they have any outbound links to topics).\n\n## Index split\n\nWhen the topic count exceeds the `index_split_threshold` config value (default 100) or any single type exceeds 30 pages, `index.md` becomes a hub of hubs and per-type sub-MOCs live at `/index/.md`. After split, new topic-page entries go into the matching sub-MOC, not the root index.\n\n## Conflict resolution\n\n- New claim contradicts existing? Add a `## Contradictions` section with a dated entry. Do NOT overwrite.\n- Flag in `log.md` for user review.\n- `wiki-refresh` reflects unresolved contradictions in the summary so query-time readers see them.\n\n## Failed sources\n\nFiles that fail ingest 3 times in a row are quarantined to `/` (default `_sources/failed/`) with a sidecar `.error.md`. Lint surfaces the quarantine count weekly.\n",Rl=["Read","Write","Edit","Glob","Grep","Bash(mv *)","Bash(mkdir *)"],Dl=["Bash(rm -rf *)","Bash(git push *)","Bash(rm -rf /*)","Bash(mv * /*)","Bash(cp -r * /*)"];async function $r(r,t){let e=(0,fe.normalizePath)(`${t}/permissions.json`),s=r.getAbstractFileByPath(e),n={allow:[],deny:[]};if(s instanceof fe.TFile)try{let o=await r.cachedRead(s),c=JSON.parse(o),l=Array.isArray(c.allow)?c.allow.filter(d=>typeof d=="string"):[],h=Array.isArray(c.deny)?c.deny.filter(d=>typeof d=="string"):[];n={allow:l,deny:h}}catch{}let a={allow:Or(n.allow,Rl),deny:Or(n.deny,Dl)},i=JSON.stringify(a,null,2)+` -`;return s instanceof fe.TFile?await r.modify(s,i):await r.create(e,i),a}function Or(r,t){let e=new Set,s=[];for(let n of r)e.has(n)||(e.add(n),s.push(n));for(let n of t)e.has(n)||(e.add(n),s.push(n));return s}async function jr(r,t,e){let s=aa(e.scopeSlug||e.scopeRoot),n=(0,fe.normalizePath)(`${t}/agents/${s}`);if(await zt(r,n),r.getAbstractFileByPath((0,fe.normalizePath)(`${n}/agent.md`)))throw new Error(`Wiki Keeper agent already exists at ${n}. Delete it first or choose a different scope slug.`);await r.create((0,fe.normalizePath)(`${n}/agent.md`),_l(s,e.scopeRoot)),await r.create((0,fe.normalizePath)(`${n}/config.md`),Al(e)),await r.create((0,fe.normalizePath)(`${n}/HEARTBEAT.md`),El(e)),await r.create((0,fe.normalizePath)(`${n}/CONTEXT.md`),Pl),await $r(r,n);let i=ra(t,s);r.getAbstractFileByPath(i)||(await zt(r,(0,fe.normalizePath)(`${t}/tasks`)),await r.create(i,Ur(s)));let o=e.scopeRoot.trim(),c=o?`${o}/`:"";return await zt(r,(0,fe.normalizePath)(`${c}${e.inboxPath}`)),await zt(r,(0,fe.normalizePath)(`${c}${e.topicsRoot}`)),await Nr(r,(0,fe.normalizePath)(`${c}${e.indexPath}`),`# Index +`)}function Li(a){let t={task_id:`${a}-lint`,agent:a,type:"recurring",schedule:ad,priority:"low",enabled:!0,created:new Date().toISOString(),run_count:0,catch_up:!1,tags:["wiki-keeper","lint"]};return U(t,"Run the `wiki-lint` skill on the current scope.\n\nWrite the report as a new `## Lint YYYY-MM-DD` section inside the fenced block in log.md.\n")}function Ja(a,e){return(0,le.normalizePath)(`${a}/tasks/${e}-lint.md`)}var ld="# Wiki Schema\n\n## Page types\n\n- **Entity pages** \u2014 people, orgs, products. Frontmatter: `type: entity`.\n- **Concept pages** \u2014 ideas, techniques, patterns. `type: concept`.\n- **Event pages** \u2014 meetings, releases, decisions. `type: event, date: YYYY-MM-DD`.\n- **Summary pages** (inbox-mode only) \u2014 one per ingested inbox source. `type: summary, source: `.\n- **Synthesis pages** \u2014 filed answers from `wiki-query` substantive responses. `type: synthesis, question: , refreshed: `. Live under `_topics/syntheses/`.\n\nTopic pages of type entity/concept/event SHOULD also carry:\n- `summary_refreshed: ` \u2014 last time `wiki-refresh` regenerated this page's summary block. Empty string on creation.\n- `claims_at_refresh: ` \u2014 how many claims existed at last refresh. Drives the staleness threshold.\n\n## Page anatomy\n\nEvery entity/concept/event page has this structure:\n\n```markdown\n---\ntype: entity\nname: Vendor X\nsummary_refreshed: 2026-04-26\nclaims_at_refresh: 14\n---\n\n\n## Summary\n\n- 3-7 bullet synthesis maintained by wiki-refresh.\n- Forward-links to other topics with [[wikilinks]].\n\n\n## Claims\n\n- 2026-04-18 [meeting]: from [[meetings/2026-04-18-vendor-sync|vendor-sync]]: Vendor X raised prices 15%\n- 2026-04-12 [doc]: from [[summaries/2026-04-12-renewal-brief|renewal-brief]]: contract auto-renews 2026-12-01\n\n## Contradictions\n\n(only present when contradictions exist)\n```\n\nThe fenced summary block is auto-managed by `wiki-refresh`. The `## Claims` section is append-only history written by `wiki-ingest` and `wiki-query` compounding. `## Contradictions` is created on demand. **Anything outside these structures is user-authored** and must be preserved.\n\n## Source-type tags\n\nEvery dated bullet in `## Claims` carries a tag indicating the source type:\n\n- `[doc]` \u2014 PDF, DOCX, XLSX, or other document\n- `[meeting]` \u2014 meeting note or transcript\n- `[email]` \u2014 email forward\n- `[note]` \u2014 markdown daily note or in-vault note\n- `[web]` \u2014 web clipping or URL drop\n- `[synthesis]` \u2014 bullet emitted by `wiki-query` compounding (links to a synthesis page)\n- `[other]` \u2014 fallback\n\nTags help `wiki-query` weight evidence quality and help `wiki-lint` identify drift.\n\n## Naming\n\n- Slug-case filenames: `vendor-x.md`, not `Vendor X.md`.\n- Group by type under `_topics//` when there are >5 pages of a type.\n\n## Links\n\n- Every entity/concept page MUST have \u22651 inbound link from `index.md` (or a sub-MOC) or a sibling.\n- Summary pages MUST forward-link to every entity/concept they mention.\n- Watched-mode extractions append dated entries to topic pages; forward-link to the source file path so readers can find the raw note.\n- Synthesis pages forward-link to every cited topic and earn their place that way (lint exempts them from orphan flagging if they have any outbound links to topics).\n\n## Index split\n\nWhen the topic count exceeds the `index_split_threshold` config value (default 100) or any single type exceeds 30 pages, `index.md` becomes a hub of hubs and per-type sub-MOCs live at `/index/.md`. After split, new topic-page entries go into the matching sub-MOC, not the root index.\n\n## Conflict resolution\n\n- New claim contradicts existing? Add a `## Contradictions` section with a dated entry. Do NOT overwrite.\n- Flag in `log.md` for user review.\n- `wiki-refresh` reflects unresolved contradictions in the summary so query-time readers see them.\n\n## Failed sources\n\nFiles that fail ingest 3 times in a row are quarantined to `/` (default `_sources/failed/`) with a sidecar `.error.md`. Lint surfaces the quarantine count weekly.\n",cd=["Read","Write","Edit","Glob","Grep","Bash(mv *)","Bash(mkdir *)"],dd=["Bash(rm -rf *)","Bash(git push *)","Bash(rm -rf /*)","Bash(mv * /*)","Bash(cp -r * /*)"];async function Oi(a,e){let t=(0,le.normalizePath)(`${e}/permissions.json`),s=a.getAbstractFileByPath(t),n={allow:[],deny:[]};if(s instanceof le.TFile)try{let o=await a.cachedRead(s),l=JSON.parse(o),c=Array.isArray(l.allow)?l.allow.filter(u=>typeof u=="string"):[],d=Array.isArray(l.deny)?l.deny.filter(u=>typeof u=="string"):[];n={allow:c,deny:d}}catch{}let r={allow:Ii(n.allow,cd),deny:Ii(n.deny,dd)},i=JSON.stringify(r,null,2)+` +`;return s instanceof le.TFile?await a.modify(s,i):await a.create(t,i),r}function Ii(a,e){let t=new Set,s=[];for(let n of a)t.has(n)||(t.add(n),s.push(n));for(let n of e)t.has(n)||(t.add(n),s.push(n));return s}async function Ni(a,e,t){let s=Ka(t.scopeSlug||t.scopeRoot),n=(0,le.normalizePath)(`${e}/agents/${s}`);if(await ms(a,n),a.getAbstractFileByPath((0,le.normalizePath)(`${n}/agent.md`)))throw new Error(`Wiki Keeper agent already exists at ${n}. Delete it first or choose a different scope slug.`);await a.create((0,le.normalizePath)(`${n}/agent.md`),rd(s,t.scopeRoot)),await a.create((0,le.normalizePath)(`${n}/config.md`),id(t)),await a.create((0,le.normalizePath)(`${n}/HEARTBEAT.md`),od(t)),await a.create((0,le.normalizePath)(`${n}/CONTEXT.md`),ld),await Oi(a,n);let i=Ja(e,s);a.getAbstractFileByPath(i)||(await ms(a,(0,le.normalizePath)(`${e}/tasks`)),await a.create(i,Li(s)));let o=t.scopeRoot.trim(),l=o?`${o}/`:"";return await ms(a,(0,le.normalizePath)(`${l}${t.inboxPath}`)),await ms(a,(0,le.normalizePath)(`${l}${t.topicsRoot}`)),await Mi(a,(0,le.normalizePath)(`${l}${t.indexPath}`),`# Index -`),await Nr(r,(0,fe.normalizePath)(`${c}${e.logPath}`),`# Log +`),await Mi(a,(0,le.normalizePath)(`${l}${t.logPath}`),`# Log -`),{path:n,name:s}}async function zt(r,t){if(r.getAbstractFileByPath(t))return;let e=t.split("/"),s="";for(let n of e)if(s=s?`${s}/${n}`:n,!r.getAbstractFileByPath(s))try{await r.createFolder(s)}catch(a){if(!(a instanceof Error?a.message:String(a)).includes("already exists"))throw a}}async function Nr(r,t,e){if(r.getAbstractFileByPath(t))return;let s=t.replace(/\/[^/]+$/,"");s&&s!==t&&await zt(r,s),await r.create(t,e)}async function Wr(r,t,e,s){let n=(0,fe.normalizePath)(`${t}/agents/${e}`),a=(0,fe.normalizePath)(`${n}/config.md`),i=r.getAbstractFileByPath(a);if(!(i instanceof fe.TFile))throw new Error(`Config file not found for ${e} at ${a}`);let o=await r.cachedRead(i),{frontmatter:c,body:l}=J(o),h=c.wiki_keeper??{};h.watched_folders=s.watchedFolders,h.exclude_patterns=s.excludePatterns,s.watchedSince?h.watched_since=s.watchedSince:delete h.watched_since,delete h.ingest_schedule,delete h.lint_schedule,delete h.lint_day,h.file_substantive_answers=s.fileSubstantiveAnswers,h.obsidian_url_scheme=s.obsidianUrlScheme,h.max_tokens_per_ingest=s.maxTokensPerIngest,h.max_tokens_per_refresh=s.maxTokensPerRefresh,h.index_split_threshold=s.indexSplitThreshold,h.dedup_similarity_threshold=s.dedupSimilarityThreshold,h.summary_stale_days=s.summaryStaleDays,(typeof h.failed_path!="string"||!h.failed_path)&&(h.failed_path="_sources/failed"),c.wiki_keeper=h,delete c.allowed_tools,delete c.blocked_tools,await r.modify(i,H(c,l)),await $r(r,n);let d=(0,fe.normalizePath)(`${n}/HEARTBEAT.md`),u=r.getAbstractFileByPath(d);if(u instanceof fe.TFile){let f=await r.cachedRead(u),{frontmatter:g,body:y}=J(f);s.heartbeatChannel?g.channel=s.heartbeatChannel:delete g.channel,await r.modify(u,H(g,y))}let p=ra(t,e);r.getAbstractFileByPath(p)instanceof fe.TFile||(await zt(r,(0,fe.normalizePath)(`${t}/tasks`)),await r.create(p,Ur(e)))}async function Hr(r,t,e){let s=r.vault,n=(0,fe.normalizePath)(`${t}/agents/${e}`),a=s.getAbstractFileByPath(n);if(!a)return;if(!(a instanceof fe.TFolder))throw new Error(`Expected folder at ${n}`);await r.fileManager.trashFile(a);let i=ra(t,e),o=s.getAbstractFileByPath(i);o instanceof fe.TFile&&await r.fileManager.trashFile(o)}var en=class extends F.PluginSettingTab{constructor(e){super(e.app,e);this.plugin=e}display(){let{containerEl:e}=this;e.empty(),new F.Setting(e).setName("Fleet folder").addText(a=>a.setValue(this.plugin.settings.fleetFolder).onChange(async i=>{this.plugin.settings.fleetFolder=i.trim()||it.fleetFolder,await this.plugin.saveSettings()})),new F.Setting(e).setName("Claude CLI path").addText(a=>a.setValue(this.plugin.settings.claudeCliPath).onChange(async i=>{this.plugin.settings.claudeCliPath=i.trim()||it.claudeCliPath,await this.plugin.saveSettings()})),new F.Setting(e).setName("Codex CLI path").setDesc("Used by agents with adapter \u201Ccodex\u201D. Install via `npm i -g @openai/codex` and authenticate with `codex login` in a terminal first.").addText(a=>a.setValue(this.plugin.settings.codexCliPath).onChange(async i=>{this.plugin.settings.codexCliPath=i.trim()||it.codexCliPath,await this.plugin.saveSettings()}));let n=new F.Setting(e).setName("Default model").setDesc("Fallback for agents that don\u2019t set their own. Aliases (opus/sonnet/haiku) work on any backend; use Custom for pinned IDs or Bedrock/Vertex/Foundry.").controlEl.createDiv();Rt(n,{value:this.plugin.settings.defaultModel,onChange:async a=>{this.plugin.settings.defaultModel=a||it.defaultModel,await this.plugin.saveSettings()}}),new F.Setting(e).setName("AWS region").addText(a=>a.setValue(this.plugin.settings.awsRegion).onChange(async i=>{this.plugin.settings.awsRegion=i.trim()||it.awsRegion,await this.plugin.saveSettings()})),new F.Setting(e).setName("Max concurrent runs").addSlider(a=>a.setLimits(1,10,1).setValue(this.plugin.settings.maxConcurrentRuns).setDynamicTooltip().onChange(async i=>{this.plugin.settings.maxConcurrentRuns=i,await this.plugin.saveSettings()})),new F.Setting(e).setName("Run log retention").setDesc("Days to keep run logs before auto-prune.").addSlider(a=>a.setLimits(1,365,1).setValue(this.plugin.settings.runLogRetentionDays).setDynamicTooltip().onChange(async i=>{this.plugin.settings.runLogRetentionDays=i,await this.plugin.saveSettings()})),new F.Setting(e).setName("Catch up missed tasks").addToggle(a=>a.setValue(this.plugin.settings.catchUpMissedTasks).onChange(async i=>{this.plugin.settings.catchUpMissedTasks=i,await this.plugin.saveSettings()})),new F.Setting(e).setName("Notification level").addDropdown(a=>a.addOption("all","All").addOption("failures-only","Failures only").addOption("none","None").setValue(this.plugin.settings.notificationLevel).onChange(async i=>{this.plugin.settings.notificationLevel=i,await this.plugin.saveSettings()})),new F.Setting(e).setName("Status bar").addToggle(a=>a.setValue(this.plugin.settings.showStatusBar).onChange(async i=>{this.plugin.settings.showStatusBar=i,await this.plugin.saveSettings(),this.plugin.refreshStatusBar()})),new F.Setting(e).setName("Chat watchdog timeout (minutes)").setDesc("How long a chat session waits for any output from the Claude CLI before killing the subprocess and surfacing a timeout error. Raise this if your agents run long tools (e.g. large web fetches, builds) and you're seeing spurious 'no response' errors.").addSlider(a=>a.setLimits(1,60,1).setValue(this.plugin.settings.chatWatchdogMinutes).setDynamicTooltip().onChange(async i=>{this.plugin.settings.chatWatchdogMinutes=i,await this.plugin.saveSettings()})),new F.Setting(e).setName("Verify Claude CLI").setDesc("Checks that the configured binary is reachable.").addButton(a=>a.setButtonText("Verify").onClick(async()=>{let i=await this.plugin.verifyClaudeCli();new F.Notice(i?"Claude CLI detected.":"Claude CLI check failed. See console for details.")})),new F.Setting(e).setName("Verify Codex CLI").setDesc("Checks that the configured Codex binary is reachable.").addButton(a=>a.setButtonText("Verify").onClick(async()=>{let i=await this.plugin.verifyCodexCli();new F.Notice(i?"Codex CLI detected.":"Codex CLI check failed. See console for details.")})),this.renderWikiKeepersSection(e),this.renderChannelsSection(e)}renderChannelsSection(e){new F.Setting(e).setName("Channels").setHeading();let s=e.createDiv({cls:"af-settings-warning"});s.setCssStyles({padding:"12px"}),s.setCssStyles({margin:"8px 0 16px 0"}),s.setCssStyles({border:"1px solid var(--background-modifier-border)"}),s.setCssStyles({borderRadius:"6px"}),s.setCssStyles({background:"var(--background-secondary)"}),s.createEl("strong",{text:"Credential storage: "});let n=this.plugin.app.vault.configDir;s.createSpan({text:`Channel credentials are stored in this plugin's data.json inside your vault's ${n} folder. If you sync your ${n} folder across devices, credentials will sync with it. Do not commit this file to a public git repository.`}),new F.Setting(e).setName("Max concurrent channel sessions").setDesc("Hard cap on live claude subprocesses across all channels. Oldest idle session is hibernated when exceeded.").addSlider(d=>d.setLimits(1,20,1).setValue(this.plugin.settings.maxConcurrentChannelSessions).setDynamicTooltip().onChange(async u=>{this.plugin.settings.maxConcurrentChannelSessions=u,await this.plugin.saveSettings()})),new F.Setting(e).setName("Idle timeout (minutes)").setDesc("Channel sessions with no activity for this long get their subprocess hibernated. State is preserved and the next message resumes transparently.").addSlider(d=>d.setLimits(1,120,1).setValue(this.plugin.settings.channelIdleTimeoutMinutes).setDynamicTooltip().onChange(async u=>{this.plugin.settings.channelIdleTimeoutMinutes=u,await this.plugin.saveSettings()})),new F.Setting(e).setName("Rate limit per conversation").setDesc("Maximum messages allowed per external conversation within the rolling window.").addSlider(d=>d.setLimits(1,100,1).setValue(this.plugin.settings.channelRateLimitPerConversation).setDynamicTooltip().onChange(async u=>{this.plugin.settings.channelRateLimitPerConversation=u,await this.plugin.saveSettings()})),new F.Setting(e).setName("Rate limit window (minutes)").addSlider(d=>d.setLimits(1,60,1).setValue(this.plugin.settings.channelRateLimitWindowMinutes).setDynamicTooltip().onChange(async u=>{this.plugin.settings.channelRateLimitWindowMinutes=u,await this.plugin.saveSettings()})),new F.Setting(e).setName("Channel credentials").setHeading();let a=e.createDiv({cls:"af-channel-credentials"});this.renderCredentialList(a);let i=e.createDiv({cls:"af-channel-credential-add"});i.setCssStyles({marginTop:"12px"}),i.setCssStyles({padding:"12px"}),i.setCssStyles({border:"1px dashed var(--background-modifier-border)"}),i.setCssStyles({borderRadius:"6px"}),i.createEl("strong",{text:"Add a channel credential"});let o={ref:"",type:"slack",botToken:"",appToken:""};new F.Setting(i).setName("Reference name").setDesc("Used by `credential_ref` in _fleet/channels/*.md files.").addText(d=>d.setPlaceholder("my-creds").onChange(u=>{o.ref=u.trim()})),new F.Setting(i).setName("Type").addDropdown(d=>d.addOption("slack","Slack").addOption("telegram","Telegram").addOption("discord","Discord").setValue("slack").onChange(u=>{o.type=u,c.setCssStyles({display:u==="slack"?"":"none"}),l.setCssStyles({display:u==="telegram"?"":"none"}),h.setCssStyles({display:u==="discord"?"":"none"})}));let c=i.createDiv();new F.Setting(c).setName("Bot token (xoxb-...)").addText(d=>{d.inputEl.type="password",d.setPlaceholder("xoxb-...").onChange(u=>{o.botToken=u.trim()})}),new F.Setting(c).setName("App-level token (xapp-...)").setDesc("Generated in your Slack app's Basic Information \u2192 App-Level Tokens.").addText(d=>{d.inputEl.type="password",d.setPlaceholder("xapp-...").onChange(u=>{o.appToken=u.trim()})});let l=i.createDiv();l.setCssStyles({display:"none"}),new F.Setting(l).setName("Bot token").setDesc("From @BotFather on Telegram.").addText(d=>{d.inputEl.type="password",d.setPlaceholder("123456:ABC-DEF1234...").onChange(u=>{o.botToken=u.trim()})});let h=i.createDiv();h.setCssStyles({display:"none"}),new F.Setting(h).setName("Bot token").setDesc("From the Discord Developer Portal \u2192 your application \u2192 Bot \u2192 Reset Token. Enable the Message Content intent on the same page.").addText(d=>{d.inputEl.type="password",d.setPlaceholder("MTA...").onChange(u=>{o.botToken=u.trim()})}),new F.Setting(i).addButton(d=>d.setButtonText("Add credential").setCta().onClick(async()=>{if(!o.ref||!o.botToken){new F.Notice("Fill in the reference name and bot token.");return}let u;if(o.type==="telegram")u={type:"telegram",botToken:o.botToken};else if(o.type==="discord")u={type:"discord",botToken:o.botToken};else{if(!o.appToken){new F.Notice("Slack requires both bot token and app-level token.");return}u={type:"slack",botToken:o.botToken,appToken:o.appToken}}this.plugin.channelCredentials.set(o.ref,u),new F.Notice(`Added credential \`${o.ref}\`.`),this.display()}))}renderCredentialList(e){let s=this.plugin.channelCredentials.list();if(s.length===0){e.createDiv({text:"No channel credentials configured yet.",cls:"af-muted"}).setCssStyles({color:"var(--text-muted)"});return}for(let{ref:n,entry:a}of s){let i=e.createDiv({cls:"af-channel-credential-row"});i.setCssStyles({display:"flex"}),i.setCssStyles({justifyContent:"space-between"}),i.setCssStyles({alignItems:"center"}),i.setCssStyles({padding:"8px 12px"}),i.setCssStyles({border:"1px solid var(--background-modifier-border)"}),i.setCssStyles({borderRadius:"6px"}),i.setCssStyles({marginBottom:"6px"});let o=i.createDiv();o.createEl("strong",{text:n}),o.createEl("span",{text:` \xB7 ${a.type} \xB7 ${Ml(Il(a))}`,cls:"af-muted"}).setCssStyles({color:"var(--text-muted)"});let c=i.createEl("button",{text:"Remove"});c.onclick=()=>{this.plugin.channelCredentials.delete(n),new F.Notice(`Removed credential \`${n}\`.`),this.display()}}}renderWikiKeepersSection(e){new F.Setting(e).setName("Wiki Keepers").setHeading();let s=e.createEl("p",{cls:"af-settings-hint",text:"Per-scope wiki agents. Each runs its own inbox + watched folders + topics + heartbeat. All fields on the Add form are optional \u2014 click Create with everything blank to get a whole-vault keeper with defaults."});s.setCssStyles({color:"var(--af-text-secondary)"}),s.setCssStyles({fontSize:"12px"});let a=this.plugin.runtime.getSnapshot().agents.filter(o=>o.wikiKeeper!==void 0),i=e.createDiv({cls:"af-wk-list"});if(a.length===0)i.createDiv({cls:"af-wk-empty",text:"No Wiki Keepers yet. Click Add to create one."});else for(let o of a){let c=i.createDiv({cls:"af-wk-row"}),l=c.createDiv({cls:"af-wk-row-left"});l.createSpan({cls:"af-wk-name",text:o.name});let h=o.wikiKeeper.scopeRoot||"(whole vault)";l.createSpan({cls:"af-wk-scope",text:`scope: ${h}`});let d=c.createDiv({cls:"af-wk-row-actions"}),u=d.createEl("button",{text:"Edit",cls:"af-wk-row-btn"});u.onclick=()=>{new oa(this.plugin,o,()=>{this.scheduleRerender()}).open()};let p=d.createEl("button",{text:"Delete",cls:"af-wk-row-btn af-wk-row-btn-danger"});p.onclick=()=>{new qt(this.plugin.app,{title:`Delete Wiki Keeper "${o.name}"?`,body:`This removes the agent folder at _fleet/agents/${o.name}/. +`),{path:n,name:s}}async function ms(a,e){if(a.getAbstractFileByPath(e))return;let t=e.split("/"),s="";for(let n of t)if(s=s?`${s}/${n}`:n,!a.getAbstractFileByPath(s))try{await a.createFolder(s)}catch(r){if(!(r instanceof Error?r.message:String(r)).includes("already exists"))throw r}}async function Mi(a,e,t){if(a.getAbstractFileByPath(e))return;let s=e.replace(/\/[^/]+$/,"");s&&s!==e&&await ms(a,s),await a.create(e,t)}async function Bi(a,e,t,s){let n=(0,le.normalizePath)(`${e}/agents/${t}`),r=(0,le.normalizePath)(`${n}/config.md`),i=a.getAbstractFileByPath(r);if(!(i instanceof le.TFile))throw new Error(`Config file not found for ${t} at ${r}`);let o=await a.cachedRead(i),{frontmatter:l,body:c}=W(o),d=l.wiki_keeper??{};d.watched_folders=s.watchedFolders,d.exclude_patterns=s.excludePatterns,s.watchedSince?d.watched_since=s.watchedSince:delete d.watched_since,delete d.ingest_schedule,delete d.lint_schedule,delete d.lint_day,d.file_substantive_answers=s.fileSubstantiveAnswers,d.obsidian_url_scheme=s.obsidianUrlScheme,d.max_tokens_per_ingest=s.maxTokensPerIngest,d.max_tokens_per_refresh=s.maxTokensPerRefresh,d.index_split_threshold=s.indexSplitThreshold,d.dedup_similarity_threshold=s.dedupSimilarityThreshold,d.summary_stale_days=s.summaryStaleDays,(typeof d.failed_path!="string"||!d.failed_path)&&(d.failed_path="_sources/failed"),l.wiki_keeper=d,delete l.allowed_tools,delete l.blocked_tools,await a.modify(i,U(l,c)),await Oi(a,n);let u=(0,le.normalizePath)(`${n}/HEARTBEAT.md`),h=a.getAbstractFileByPath(u);if(h instanceof le.TFile){let f=await a.cachedRead(h),{frontmatter:g,body:w}=W(f);s.heartbeatChannel?g.channel=s.heartbeatChannel:delete g.channel,await a.modify(h,U(g,w))}let p=Ja(e,t);a.getAbstractFileByPath(p)instanceof le.TFile||(await ms(a,(0,le.normalizePath)(`${e}/tasks`)),await a.create(p,Li(t)))}async function Ui(a,e,t){let s=a.vault,n=(0,le.normalizePath)(`${e}/agents/${t}`),r=s.getAbstractFileByPath(n);if(!r)return;if(!(r instanceof le.TFolder))throw new Error(`Expected folder at ${n}`);await a.fileManager.trashFile(r);let i=Ja(e,t),o=s.getAbstractFileByPath(i);o instanceof le.TFile&&await a.fileManager.trashFile(o)}var Un=class extends D.PluginSettingTab{constructor(t){super(t.app,t);this.plugin=t}display(){let{containerEl:t}=this;t.empty(),new D.Setting(t).setName("Fleet folder").addText(r=>r.setValue(this.plugin.settings.fleetFolder).onChange(async i=>{this.plugin.settings.fleetFolder=i.trim()||ft.fleetFolder,await this.plugin.saveSettings()})),new D.Setting(t).setName("Claude CLI path").addText(r=>r.setValue(this.plugin.settings.claudeCliPath).onChange(async i=>{this.plugin.settings.claudeCliPath=i.trim()||ft.claudeCliPath,await this.plugin.saveSettings()})),new D.Setting(t).setName("Codex CLI path").setDesc("Used by agents with adapter \u201Ccodex\u201D. Install via `npm i -g @openai/codex` and authenticate with `codex login` in a terminal first.").addText(r=>r.setValue(this.plugin.settings.codexCliPath).onChange(async i=>{this.plugin.settings.codexCliPath=i.trim()||ft.codexCliPath,await this.plugin.saveSettings()}));let n=new D.Setting(t).setName("Default model").setDesc("Fallback for agents that don\u2019t set their own. Aliases (opus/sonnet/haiku) work on any backend; use Custom for pinned IDs or Bedrock/Vertex/Foundry.").controlEl.createDiv();Nt(n,{value:this.plugin.settings.defaultModel,onChange:async r=>{this.plugin.settings.defaultModel=r||ft.defaultModel,await this.plugin.saveSettings()}}),new D.Setting(t).setName("AWS region").addText(r=>r.setValue(this.plugin.settings.awsRegion).onChange(async i=>{this.plugin.settings.awsRegion=i.trim()||ft.awsRegion,await this.plugin.saveSettings()})),new D.Setting(t).setName("Max concurrent runs").addSlider(r=>r.setLimits(1,10,1).setValue(this.plugin.settings.maxConcurrentRuns).setDynamicTooltip().onChange(async i=>{this.plugin.settings.maxConcurrentRuns=i,await this.plugin.saveSettings()})),new D.Setting(t).setName("Run log retention").setDesc("Days to keep run logs before auto-prune.").addSlider(r=>r.setLimits(1,365,1).setValue(this.plugin.settings.runLogRetentionDays).setDynamicTooltip().onChange(async i=>{this.plugin.settings.runLogRetentionDays=i,await this.plugin.saveSettings()})),new D.Setting(t).setName("Catch up missed tasks").addToggle(r=>r.setValue(this.plugin.settings.catchUpMissedTasks).onChange(async i=>{this.plugin.settings.catchUpMissedTasks=i,await this.plugin.saveSettings()})),new D.Setting(t).setName("Notification level").addDropdown(r=>r.addOption("all","All").addOption("failures-only","Failures only").addOption("none","None").setValue(this.plugin.settings.notificationLevel).onChange(async i=>{this.plugin.settings.notificationLevel=i,await this.plugin.saveSettings()})),new D.Setting(t).setName("Status bar").addToggle(r=>r.setValue(this.plugin.settings.showStatusBar).onChange(async i=>{this.plugin.settings.showStatusBar=i,await this.plugin.saveSettings(),this.plugin.refreshStatusBar()})),new D.Setting(t).setName("Chat watchdog timeout (minutes)").setDesc("How long a chat session waits for any output from the Claude CLI before killing the subprocess and surfacing a timeout error. Raise this if your agents run long tools (e.g. large web fetches, builds) and you're seeing spurious 'no response' errors.").addSlider(r=>r.setLimits(1,60,1).setValue(this.plugin.settings.chatWatchdogMinutes).setDynamicTooltip().onChange(async i=>{this.plugin.settings.chatWatchdogMinutes=i,await this.plugin.saveSettings()})),new D.Setting(t).setName("Verify Claude CLI").setDesc("Checks that the configured binary is reachable.").addButton(r=>r.setButtonText("Verify").onClick(async()=>{let i=await this.plugin.verifyClaudeCli();new D.Notice(i?"Claude CLI detected.":"Claude CLI check failed. See console for details.")})),new D.Setting(t).setName("Verify Codex CLI").setDesc("Checks that the configured Codex binary is reachable.").addButton(r=>r.setButtonText("Verify").onClick(async()=>{let i=await this.plugin.verifyCodexCli();new D.Notice(i?"Codex CLI detected.":"Codex CLI check failed. See console for details.")})),this.renderWikiKeepersSection(t),this.renderChannelsSection(t)}renderChannelsSection(t){new D.Setting(t).setName("Channels").setHeading();let s=t.createDiv({cls:"af-settings-warning"});s.setCssStyles({padding:"12px"}),s.setCssStyles({margin:"8px 0 16px 0"}),s.setCssStyles({border:"1px solid var(--background-modifier-border)"}),s.setCssStyles({borderRadius:"6px"}),s.setCssStyles({background:"var(--background-secondary)"}),s.createEl("strong",{text:"Credential storage: "});let n=this.plugin.app.vault.configDir;s.createSpan({text:`Channel credentials are stored in this plugin's data.json inside your vault's ${n} folder. If you sync your ${n} folder across devices, credentials will sync with it. Do not commit this file to a public git repository.`}),new D.Setting(t).setName("Max concurrent channel sessions").setDesc("Hard cap on live claude subprocesses across all channels. Oldest idle session is hibernated when exceeded.").addSlider(u=>u.setLimits(1,20,1).setValue(this.plugin.settings.maxConcurrentChannelSessions).setDynamicTooltip().onChange(async h=>{this.plugin.settings.maxConcurrentChannelSessions=h,await this.plugin.saveSettings()})),new D.Setting(t).setName("Idle timeout (minutes)").setDesc("Channel sessions with no activity for this long get their subprocess hibernated. State is preserved and the next message resumes transparently.").addSlider(u=>u.setLimits(1,120,1).setValue(this.plugin.settings.channelIdleTimeoutMinutes).setDynamicTooltip().onChange(async h=>{this.plugin.settings.channelIdleTimeoutMinutes=h,await this.plugin.saveSettings()})),new D.Setting(t).setName("Rate limit per conversation").setDesc("Maximum messages allowed per external conversation within the rolling window.").addSlider(u=>u.setLimits(1,100,1).setValue(this.plugin.settings.channelRateLimitPerConversation).setDynamicTooltip().onChange(async h=>{this.plugin.settings.channelRateLimitPerConversation=h,await this.plugin.saveSettings()})),new D.Setting(t).setName("Rate limit window (minutes)").addSlider(u=>u.setLimits(1,60,1).setValue(this.plugin.settings.channelRateLimitWindowMinutes).setDynamicTooltip().onChange(async h=>{this.plugin.settings.channelRateLimitWindowMinutes=h,await this.plugin.saveSettings()})),new D.Setting(t).setName("Channel credentials").setHeading();let r=t.createDiv({cls:"af-channel-credentials"});this.renderCredentialList(r);let i=t.createDiv({cls:"af-channel-credential-add"});i.setCssStyles({marginTop:"12px"}),i.setCssStyles({padding:"12px"}),i.setCssStyles({border:"1px dashed var(--background-modifier-border)"}),i.setCssStyles({borderRadius:"6px"}),i.createEl("strong",{text:"Add a channel credential"});let o={ref:"",type:"slack",botToken:"",appToken:""};new D.Setting(i).setName("Reference name").setDesc("Used by `credential_ref` in _fleet/channels/*.md files.").addText(u=>u.setPlaceholder("my-creds").onChange(h=>{o.ref=h.trim()})),new D.Setting(i).setName("Type").addDropdown(u=>u.addOption("slack","Slack").addOption("telegram","Telegram").addOption("discord","Discord").setValue("slack").onChange(h=>{o.type=h,l.setCssStyles({display:h==="slack"?"":"none"}),c.setCssStyles({display:h==="telegram"?"":"none"}),d.setCssStyles({display:h==="discord"?"":"none"})}));let l=i.createDiv();new D.Setting(l).setName("Bot token (xoxb-...)").addText(u=>{u.inputEl.type="password",u.setPlaceholder("xoxb-...").onChange(h=>{o.botToken=h.trim()})}),new D.Setting(l).setName("App-level token (xapp-...)").setDesc("Generated in your Slack app's Basic Information \u2192 App-Level Tokens.").addText(u=>{u.inputEl.type="password",u.setPlaceholder("xapp-...").onChange(h=>{o.appToken=h.trim()})});let c=i.createDiv();c.setCssStyles({display:"none"}),new D.Setting(c).setName("Bot token").setDesc("From @BotFather on Telegram.").addText(u=>{u.inputEl.type="password",u.setPlaceholder("123456:ABC-DEF1234...").onChange(h=>{o.botToken=h.trim()})});let d=i.createDiv();d.setCssStyles({display:"none"}),new D.Setting(d).setName("Bot token").setDesc("From the Discord Developer Portal \u2192 your application \u2192 Bot \u2192 Reset Token. Enable the Message Content intent on the same page.").addText(u=>{u.inputEl.type="password",u.setPlaceholder("MTA...").onChange(h=>{o.botToken=h.trim()})}),new D.Setting(i).addButton(u=>u.setButtonText("Add credential").setCta().onClick(async()=>{if(!o.ref||!o.botToken){new D.Notice("Fill in the reference name and bot token.");return}let h;if(o.type==="telegram")h={type:"telegram",botToken:o.botToken};else if(o.type==="discord")h={type:"discord",botToken:o.botToken};else{if(!o.appToken){new D.Notice("Slack requires both bot token and app-level token.");return}h={type:"slack",botToken:o.botToken,appToken:o.appToken}}this.plugin.channelCredentials.set(o.ref,h),new D.Notice(`Added credential \`${o.ref}\`.`),this.display()}))}renderCredentialList(t){let s=this.plugin.channelCredentials.list();if(s.length===0){t.createDiv({text:"No channel credentials configured yet.",cls:"af-muted"}).setCssStyles({color:"var(--text-muted)"});return}for(let{ref:n,entry:r}of s){let i=t.createDiv({cls:"af-channel-credential-row"});i.setCssStyles({display:"flex"}),i.setCssStyles({justifyContent:"space-between"}),i.setCssStyles({alignItems:"center"}),i.setCssStyles({padding:"8px 12px"}),i.setCssStyles({border:"1px solid var(--background-modifier-border)"}),i.setCssStyles({borderRadius:"6px"}),i.setCssStyles({marginBottom:"6px"});let o=i.createDiv();o.createEl("strong",{text:n}),o.createEl("span",{text:` \xB7 ${r.type} \xB7 ${hd(ud(r))}`,cls:"af-muted"}).setCssStyles({color:"var(--text-muted)"});let l=i.createEl("button",{text:"Remove"});l.onclick=()=>{this.plugin.channelCredentials.delete(n),new D.Notice(`Removed credential \`${n}\`.`),this.display()}}}renderWikiKeepersSection(t){new D.Setting(t).setName("Wiki Keepers").setHeading();let s=t.createEl("p",{cls:"af-settings-hint",text:"Per-scope wiki agents. Each runs its own inbox + watched folders + topics + heartbeat. All fields on the Add form are optional \u2014 click Create with everything blank to get a whole-vault keeper with defaults."});s.setCssStyles({color:"var(--af-text-secondary)"}),s.setCssStyles({fontSize:"12px"});let r=this.plugin.runtime.getSnapshot().agents.filter(o=>o.wikiKeeper!==void 0),i=t.createDiv({cls:"af-wk-list"});if(r.length===0)i.createDiv({cls:"af-wk-empty",text:"No Wiki Keepers yet. Click Add to create one."});else for(let o of r){let l=i.createDiv({cls:"af-wk-row"}),c=l.createDiv({cls:"af-wk-row-left"});c.createSpan({cls:"af-wk-name",text:o.name});let d=o.wikiKeeper.scopeRoot||"(whole vault)";c.createSpan({cls:"af-wk-scope",text:`scope: ${d}`});let u=l.createDiv({cls:"af-wk-row-actions"}),h=u.createEl("button",{text:"Edit",cls:"af-wk-row-btn"});h.onclick=()=>{new Qa(this.plugin,o,()=>{this.scheduleRerender()}).open()};let p=u.createEl("button",{text:"Delete",cls:"af-wk-row-btn af-wk-row-btn-danger"});p.onclick=()=>{new ut(this.plugin.app,{title:`Delete Wiki Keeper "${o.name}"?`,body:`This removes the agent folder at _fleet/agents/${o.name}/. -Your scope's inbox, topics, index, and log are NOT deleted.`,confirmText:"Delete",danger:!0,onConfirm:async()=>{try{await Hr(this.plugin.app,this.plugin.settings.fleetFolder,o.name),await this.plugin.refreshFromVault(),new F.Notice(`Wiki Keeper "${o.name}" deleted.`),this.scheduleRerender()}catch(m){let f=m instanceof Error?m.message:String(m);new F.Notice(`Failed to delete: ${f}`)}}}).open()}}new F.Setting(e).setName("Add Wiki Keeper").setDesc("Create a new scoped wiki agent. All fields optional.").addButton(o=>o.setButtonText("+ Add").onClick(()=>{new ia(this.plugin,()=>{this.scheduleRerender()}).open()}))}scheduleRerender(){window.setTimeout(()=>{(async()=>{try{await this.plugin.refreshFromVault()}catch{}this.display()})()},600)}},ia=class extends F.Modal{constructor(e,s){super(e.app);this.plugin=e;this.onCreated=s;this.input=na()}input;onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("af-wk-modal"),e.createEl("h2",{text:"Add Wiki Keeper"});let s=e.createDiv({cls:"af-wk-banner"});s.createEl("strong",{text:"All fields are optional. "}),s.createSpan({text:"Click Create with everything blank and you'll get a whole-vault keeper with sensible defaults (inbox at _sources/inbox, topics at topics/, nightly ingest at 3am, Sunday lint)."}),e.createEl("h3",{text:"Scope",cls:"af-wk-section-h"}),new F.Setting(e).setName("Scope folder").setDesc("Optional. Vault-relative path. Empty = whole vault.").addText(h=>h.setPlaceholder("(empty = whole vault)").setValue(this.input.scopeRoot).onChange(d=>{this.input.scopeRoot=d.trim(),this.renderPreview()})),new F.Setting(e).setName("Slug").setDesc("Optional. Agent name suffix. Default: derived from scope folder name, or 'wiki-keeper' for whole-vault.").addText(h=>h.setPlaceholder("(auto)").setValue(this.input.scopeSlug).onChange(d=>{this.input.scopeSlug=d.trim(),this.renderPreview()})),this.renderPreview(),e.createEl("h3",{text:"Sources",cls:"af-wk-section-h"}),new F.Setting(e).setName("Watched folders").setDesc("Optional. Comma- or newline-separated vault-relative paths. Read-only \u2014 claims are extracted but files are never moved. Leave empty for inbox-only mode (you drop files into _sources/inbox/ and they get archived after processing).").addTextArea(h=>h.setPlaceholder("(empty = inbox-only)").setValue(this.input.watchedFolders.join(", ")).onChange(d=>{this.input.watchedFolders=d.split(/[,\n]/).map(u=>u.trim()).filter(Boolean)})),new F.Setting(e).setName("Exclude patterns").setDesc("Optional. Glob patterns to skip. Leave empty to process everything under watched + inbox.").addTextArea(h=>h.setPlaceholder("(empty = no excludes)").setValue(this.input.excludePatterns.join(", ")).onChange(d=>{this.input.excludePatterns=d.split(/[,\n]/).map(u=>u.trim()).filter(Boolean)})),new F.Setting(e).setName("Watch files modified since").setDesc("Watched mode only \u2014 files whose last modification date is before this are skipped. Defaults to today so an established vault doesn't flood the keeper on first run. Clear the field to process everything.").addText(h=>{h.inputEl.type="date",h.setValue(this.input.watchedSince).onChange(d=>{this.input.watchedSince=d.trim()})});let n=this.plugin.runtime.getSnapshot().channels.map(h=>h.name);new F.Setting(e).setName("Heartbeat channel").setDesc("Optional. Slack/Telegram channel for ingest + lint summaries. Leave as (none) to disable.").addDropdown(h=>{h.addOption("","(none)");for(let d of n)h.addOption(d,d);h.setValue(this.input.heartbeatChannel).onChange(d=>{this.input.heartbeatChannel=d})}),e.createEl("h3",{text:"Advanced",cls:"af-wk-section-h"}),new F.Setting(e).setName("Max tokens per ingest").setDesc("Hard cap on token spend per run. Unprocessed files resume next cycle.").addText(h=>h.setValue(String(this.input.maxTokensPerIngest)).onChange(d=>{let u=parseInt(d,10);!isNaN(u)&&u>0&&(this.input.maxTokensPerIngest=u)})),new F.Setting(e).setName("File substantive answers").setDesc("If on, wiki-query files long answers under _topics/syntheses/. Off = answers live only in chat.").addToggle(h=>h.setValue(this.input.fileSubstantiveAnswers).onChange(d=>{this.input.fileSubstantiveAnswers=d})),new F.Setting(e).setName("Obsidian URL scheme for citations").setDesc("If on, external-channel (Slack/Telegram) replies rewrite [[wikilinks]] as obsidian:// URLs.").addToggle(h=>h.setValue(this.input.obsidianUrlScheme).onChange(d=>{this.input.obsidianUrlScheme=d}));let a=e.createEl("h3",{text:"Paths (expert)",cls:"af-wk-section-h"});a.title="Paths are relative to scope_root. Defaults work for almost everyone. Override only if your vault layout demands it \u2014 these cannot be changed after creation.";let i=(h,d,u)=>{new F.Setting(e).setName(h).setDesc(d).addText(p=>p.setPlaceholder(String(this.input[u]??"")).setValue(String(this.input[u]??"")).onChange(m=>{this.input[u]=m.trim()||Br[u]}))};i("Inbox path","Where one-off source drops land.","inboxPath"),i("Archive path","Where processed inbox files are moved after summarization.","archivePath"),i("Topics root","Folder under scope_root that holds the generated topic pages.","topicsRoot"),i("Index path","Relative file path for the curated index.","indexPath"),i("Log path","Relative file path for the keeper's activity log.","logPath"),i("State file","Hidden JSON file that tracks watched-source mtimes.","stateFile");let o=e.createDiv({cls:"af-wk-modal-footer"}),c=o.createEl("button",{text:"Cancel"});c.onclick=()=>this.close();let l=o.createEl("button",{text:"Create",cls:"mod-cta"});l.onclick=async()=>{l.setText("Creating\u2026"),l.disabled=!0;try{let{name:h}=await jr(this.app.vault,this.plugin.settings.fleetFolder,this.input);await this.plugin.refreshFromVault(),this.close(),new F.Notice(`Wiki Keeper "${h}" created.`),this.onCreated()}catch(h){let d=h instanceof Error?h.message:String(h);new F.Notice(`Failed to create Wiki Keeper: ${d}`),l.setText("Create"),l.disabled=!1}}}renderPreview(){let e=this.contentEl.querySelector(".af-wk-name-preview");e||(e=this.contentEl.createDiv({cls:"af-wk-name-preview"}));let s=this.input.scopeSlug||this.input.scopeRoot;e.setText(`\u2192 Will create agent: ${aa(s)}`)}onClose(){this.contentEl.empty()}},oa=class extends F.Modal{constructor(e,s,n){super(e.app);this.plugin=e;this.agent=s;this.onSaved=n;let a=s.wikiKeeper;this.edit={watchedFolders:[...a.watchedFolders],excludePatterns:[...a.excludePatterns],watchedSince:a.watchedSince,heartbeatChannel:s.heartbeatChannel??"",fileSubstantiveAnswers:a.fileSubstantiveAnswers,obsidianUrlScheme:a.obsidianUrlScheme,maxTokensPerIngest:a.maxTokensPerIngest,maxTokensPerRefresh:a.maxTokensPerRefresh,indexSplitThreshold:a.indexSplitThreshold,dedupSimilarityThreshold:a.dedupSimilarityThreshold,summaryStaleDays:a.summaryStaleDays}}edit;onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("af-wk-modal"),e.createEl("h2",{text:`Edit ${this.agent.name}`});let s=e.createDiv({cls:"af-wk-banner"}),n=this.agent.wikiKeeper.scopeRoot||"(whole vault)";s.createEl("strong",{text:"Scope: "}),s.createSpan({text:n}),s.createEl("br"),s.createSpan({text:"Scope and paths are fixed after creation \u2014 changing them would orphan existing topic pages. To move a Wiki Keeper, delete this one and create a new one at the new scope."}),e.createEl("h3",{text:"Sources",cls:"af-wk-section-h"}),new F.Setting(e).setName("Watched folders").setDesc("Comma- or newline-separated vault-relative paths.").addTextArea(l=>l.setValue(this.edit.watchedFolders.join(", ")).onChange(h=>{this.edit.watchedFolders=h.split(/[,\n]/).map(d=>d.trim()).filter(Boolean)})),new F.Setting(e).setName("Exclude patterns").setDesc("Glob patterns to skip.").addTextArea(l=>l.setValue(this.edit.excludePatterns.join(", ")).onChange(h=>{this.edit.excludePatterns=h.split(/[,\n]/).map(d=>d.trim()).filter(Boolean)})),new F.Setting(e).setName("Watch files modified since").setDesc("Watched mode skips files older than this date. Clear to process everything.").addText(l=>{l.inputEl.type="date",l.setValue(this.edit.watchedSince).onChange(h=>{this.edit.watchedSince=h.trim()})});let a=this.plugin.runtime.getSnapshot().channels.map(l=>l.name);new F.Setting(e).setName("Heartbeat channel").addDropdown(l=>{l.addOption("","(none)");for(let h of a)l.addOption(h,h);l.setValue(this.edit.heartbeatChannel).onChange(h=>{this.edit.heartbeatChannel=h})}),e.createEl("h3",{text:"Advanced",cls:"af-wk-section-h"}),new F.Setting(e).setName("Max tokens per ingest").addText(l=>l.setValue(String(this.edit.maxTokensPerIngest)).onChange(h=>{let d=parseInt(h,10);!isNaN(d)&&d>0&&(this.edit.maxTokensPerIngest=d)})),new F.Setting(e).setName("Max tokens per refresh").setDesc("Separate budget for the synthesis-refresh phase that regenerates topic-page summaries.").addText(l=>l.setValue(String(this.edit.maxTokensPerRefresh)).onChange(h=>{let d=parseInt(h,10);!isNaN(d)&&d>0&&(this.edit.maxTokensPerRefresh=d)})),new F.Setting(e).setName("Index split threshold").setDesc("Topic-page count above which index.md splits into per-type sub-MOCs.").addText(l=>l.setValue(String(this.edit.indexSplitThreshold)).onChange(h=>{let d=parseInt(h,10);!isNaN(d)&&d>0&&(this.edit.indexSplitThreshold=d)})),new F.Setting(e).setName("Summary stale (days)").setDesc("Lint flags topic-page summaries older than this and chains wiki-refresh.").addText(l=>l.setValue(String(this.edit.summaryStaleDays)).onChange(h=>{let d=parseInt(h,10);!isNaN(d)&&d>0&&(this.edit.summaryStaleDays=d)})),new F.Setting(e).setName("Dedup similarity threshold").setDesc("Levenshtein-ratio above which lint proposes merging two same-type pages (0.0\u20131.0).").addText(l=>l.setValue(String(this.edit.dedupSimilarityThreshold)).onChange(h=>{let d=parseFloat(h);!isNaN(d)&&d>0&&d<=1&&(this.edit.dedupSimilarityThreshold=d)})),new F.Setting(e).setName("File substantive answers").setDesc("Compounding: wiki-query files long answers under _topics/syntheses/ and bullets each cited topic page. Default ON.").addToggle(l=>l.setValue(this.edit.fileSubstantiveAnswers).onChange(h=>{this.edit.fileSubstantiveAnswers=h})),new F.Setting(e).setName("Obsidian URL scheme for citations").setDesc("Rewrite [[wikilinks]] as obsidian:// URLs when replying via Slack/Telegram.").addToggle(l=>l.setValue(this.edit.obsidianUrlScheme).onChange(h=>{this.edit.obsidianUrlScheme=h}));let i=e.createDiv({cls:"af-wk-modal-footer"}),o=i.createEl("button",{text:"Cancel"});o.onclick=()=>this.close();let c=i.createEl("button",{text:"Save",cls:"mod-cta"});c.onclick=async()=>{c.setText("Saving\u2026"),c.disabled=!0;try{await Wr(this.app.vault,this.plugin.settings.fleetFolder,this.agent.name,this.edit),await this.plugin.refreshFromVault(),this.close(),new F.Notice("Saved."),this.onSaved()}catch(l){let h=l instanceof Error?l.message:String(l);new F.Notice(`Failed to save: ${h}`),c.setText("Save"),c.disabled=!1}}}onClose(){this.contentEl.empty()}};function Il(r){return r.type==="slack",r.botToken}function Ml(r){return r.length<=10?"***":`${r.slice(0,6)}\u2026${r.slice(-4)}`}var Sa=require("crypto");function He(r,t,e,s,n,a,i,o){return He.fromTZ(He.tp(r,t,e,s,n,a,i),o)}He.fromTZISO=(r,t,e)=>He.fromTZ(Ll(r,t),e);He.fromTZ=function(r,t){let e=new Date(Date.UTC(r.y,r.m-1,r.d,r.h,r.i,r.s)),s=la(r.tz,e),n=new Date(e.getTime()-s),a=la(r.tz,n);if(a-s===0)return n;{let i=new Date(e.getTime()-a),o=la(r.tz,i);if(o-a===0)return i;if(!t&&o-a>0)return i;if(t)throw new Error("Invalid date passed to fromTZ()");return n}};He.toTZ=function(r,t){let e=r.toLocaleString("en-US",{timeZone:t}).replace(/[\u202f]/," "),s=new Date(e);return{y:s.getFullYear(),m:s.getMonth()+1,d:s.getDate(),h:s.getHours(),i:s.getMinutes(),s:s.getSeconds(),tz:t}};He.tp=(r,t,e,s,n,a,i)=>({y:r,m:t,d:e,h:s,i:n,s:a,tz:i});function la(r,t=new Date){let e=t.toLocaleString("en-US",{timeZone:r,timeZoneName:"shortOffset"}).split(" ").slice(-1)[0],s=t.toLocaleString("en-US").replace(/[\u202f]/," ");return Date.parse(`${s} GMT`)-Date.parse(`${s} ${e}`)}function Ll(r,t){let e=new Date(Date.parse(r));if(isNaN(e))throw new Error("minitz: Invalid ISO8601 passed to parser.");let s=r.substring(9);return r.includes("Z")||s.includes("-")||s.includes("+")?He.tp(e.getUTCFullYear(),e.getUTCMonth()+1,e.getUTCDate(),e.getUTCHours(),e.getUTCMinutes(),e.getUTCSeconds(),"Etc/UTC"):He.tp(e.getFullYear(),e.getMonth()+1,e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),t)}He.minitz=He;function Fl(r){if(r===void 0&&(r={}),delete r.name,r.legacyMode=r.legacyMode===void 0?!0:r.legacyMode,r.paused=r.paused===void 0?!1:r.paused,r.maxRuns=r.maxRuns===void 0?1/0:r.maxRuns,r.catch=r.catch===void 0?!1:r.catch,r.interval=r.interval===void 0?0:parseInt(r.interval,10),r.utcOffset=r.utcOffset===void 0?void 0:parseInt(r.utcOffset,10),r.unref=r.unref===void 0?!1:r.unref,r.startAt&&(r.startAt=new _e(r.startAt,r.timezone)),r.stopAt&&(r.stopAt=new _e(r.stopAt,r.timezone)),r.interval!==null){if(isNaN(r.interval))throw new Error("CronOptions: Supplied value for interval is not a number");if(r.interval<0)throw new Error("CronOptions: Supplied value for interval can not be negative")}if(r.utcOffset!==void 0){if(isNaN(r.utcOffset))throw new Error("CronOptions: Invalid value passed for utcOffset, should be number representing minutes offset from UTC.");if(r.utcOffset<-870||r.utcOffset>870)throw new Error("CronOptions: utcOffset out of bounds.");if(r.utcOffset!==void 0&&r.timezone)throw new Error("CronOptions: Combining 'utcOffset' with 'timezone' is not allowed.")}if(r.unref!==!0&&r.unref!==!1)throw new Error("CronOptions: Unref should be either true, false or undefined(false).");return r}var ca=32,ms=31|ca,zr=[1,2,4,8,16];function Ge(r,t){this.pattern=r,this.timezone=t,this.second=Array(60).fill(0),this.minute=Array(60).fill(0),this.hour=Array(24).fill(0),this.day=Array(31).fill(0),this.month=Array(12).fill(0),this.dayOfWeek=Array(7).fill(0),this.lastDayOfMonth=!1,this.starDOM=!1,this.starDOW=!1,this.parse()}Ge.prototype.parse=function(){if(!(typeof this.pattern=="string"||this.pattern.constructor===String))throw new TypeError("CronPattern: Pattern has to be of type string.");this.pattern.indexOf("@")>=0&&(this.pattern=this.handleNicknames(this.pattern).trim());let r=this.pattern.replace(/\s+/g," ").split(" ");if(r.length<5||r.length>6)throw new TypeError("CronPattern: invalid configuration format ('"+this.pattern+"'), exactly five or six space separated parts are required.");if(r.length===5&&r.unshift("0"),r[3].indexOf("L")>=0&&(r[3]=r[3].replace("L",""),this.lastDayOfMonth=!0),r[3]=="*"&&(this.starDOM=!0),r[4].length>=3&&(r[4]=this.replaceAlphaMonths(r[4])),r[5].length>=3&&(r[5]=this.replaceAlphaDays(r[5])),r[5]=="*"&&(this.starDOW=!0),this.pattern.indexOf("?")>=0){let t=new _e(new Date,this.timezone).getDate(!0);r[0]=r[0].replace("?",t.getSeconds()),r[1]=r[1].replace("?",t.getMinutes()),r[2]=r[2].replace("?",t.getHours()),this.starDOM||(r[3]=r[3].replace("?",t.getDate())),r[4]=r[4].replace("?",t.getMonth()+1),this.starDOW||(r[5]=r[5].replace("?",t.getDay()))}this.throwAtIllegalCharacters(r),this.partToArray("second",r[0],0,1),this.partToArray("minute",r[1],0,1),this.partToArray("hour",r[2],0,1),this.partToArray("day",r[3],-1,1),this.partToArray("month",r[4],-1,1),this.partToArray("dayOfWeek",r[5],0,ms),this.dayOfWeek[7]&&(this.dayOfWeek[0]=this.dayOfWeek[7])};Ge.prototype.partToArray=function(r,t,e,s){let n=this[r],a=r==="day"&&this.lastDayOfMonth;if(t===""&&!a)throw new TypeError("CronPattern: configuration entry "+r+" ("+t+") is empty, check for trailing spaces.");if(t==="*")return n.fill(s);let i=t.split(",");if(i.length>1)for(let o=0;o6)&&t!=="L")throw new RangeError("CronPattern: Invalid value for dayOfWeek: "+t);this.setNthWeekdayOfMonth(t,e);return}if(r==="second"||r==="minute"){if(t<0||t>=60)throw new RangeError("CronPattern: Invalid value for "+r+": "+t)}else if(r==="hour"){if(t<0||t>=24)throw new RangeError("CronPattern: Invalid value for "+r+": "+t)}else if(r==="day"){if(t<0||t>=31)throw new RangeError("CronPattern: Invalid value for "+r+": "+t)}else if(r==="month"&&(t<0||t>=12))throw new RangeError("CronPattern: Invalid value for "+r+": "+t);this[r][t]=e};Ge.prototype.handleRangeWithStepping=function(r,t,e,s){let n=this.extractNth(r,t),a=n[0].match(/^(\d+)-(\d+)\/(\d+)$/);if(a===null)throw new TypeError("CronPattern: Syntax error, illegal range with stepping: '"+r+"'");let[,i,o,c]=a;if(i=parseInt(i,10)+e,o=parseInt(o,10)+e,c=parseInt(c,10),isNaN(i))throw new TypeError("CronPattern: Syntax error, illegal lower range (NaN)");if(isNaN(o))throw new TypeError("CronPattern: Syntax error, illegal upper range (NaN)");if(isNaN(c))throw new TypeError("CronPattern: Syntax error, illegal stepping: (NaN)");if(c===0)throw new TypeError("CronPattern: Syntax error, illegal stepping: 0");if(c>this[t].length)throw new TypeError("CronPattern: Syntax error, steps cannot be greater than maximum value of part ("+this[t].length+")");if(i>o)throw new TypeError("CronPattern: From value is larger than to value: '"+r+"'");for(let l=i;l<=o;l+=c)this.setPart(t,l,n[1]||s)};Ge.prototype.extractNth=function(r,t){let e=r,s;if(e.includes("#")){if(t!=="dayOfWeek")throw new Error("CronPattern: nth (#) only allowed in day-of-week field");s=e.split("#")[1],e=e.split("#")[0]}return[e,s]};Ge.prototype.handleRange=function(r,t,e,s){let n=this.extractNth(r,t),a=n[0].split("-");if(a.length!==2)throw new TypeError("CronPattern: Syntax error, illegal range: '"+r+"'");let i=parseInt(a[0],10)+e,o=parseInt(a[1],10)+e;if(isNaN(i))throw new TypeError("CronPattern: Syntax error, illegal lower range (NaN)");if(isNaN(o))throw new TypeError("CronPattern: Syntax error, illegal upper range (NaN)");if(i>o)throw new TypeError("CronPattern: From value is larger than to value: '"+r+"'");for(let c=i;c<=o;c++)this.setPart(t,c,n[1]||s)};Ge.prototype.handleStepping=function(r,t,e,s){let n=this.extractNth(r,t),a=n[0].split("/");if(a.length!==2)throw new TypeError("CronPattern: Syntax error, illegal stepping: '"+r+"'");let i=0;a[0]!=="*"&&(i=parseInt(a[0],10)+e);let o=parseInt(a[1],10);if(isNaN(o))throw new TypeError("CronPattern: Syntax error, illegal stepping: (NaN)");if(o===0)throw new TypeError("CronPattern: Syntax error, illegal stepping: 0");if(o>this[t].length)throw new TypeError("CronPattern: Syntax error, max steps for part is ("+this[t].length+")");for(let c=i;c0)this.dayOfWeek[r]=this.dayOfWeek[r]|zr[t-1];else if(t===ms)this.dayOfWeek[r]=ms;else throw new TypeError(`CronPattern: nth weekday of of range, should be 1-5 or L. Value: ${t}`)};var Gr=[31,28,31,30,31,30,31,31,30,31,30,31],ft=[["month","year",0],["day","month",-1],["hour","day",0],["minute","hour",0],["second","minute",0]];function _e(r,t){if(this.tz=t,r&&r instanceof Date)if(!isNaN(r))this.fromDate(r);else throw new TypeError("CronDate: Invalid date passed to CronDate constructor");else if(r===void 0)this.fromDate(new Date);else if(r&&typeof r=="string")this.fromString(r);else if(r instanceof _e)this.fromCronDate(r);else throw new TypeError("CronDate: Invalid type ("+typeof r+") passed to CronDate constructor")}_e.prototype.isNthWeekdayOfMonth=function(r,t,e,s){let a=new Date(Date.UTC(r,t,e)).getUTCDay(),i=0;for(let o=1;o<=e;o++)new Date(Date.UTC(r,t,o)).getUTCDay()===a&&i++;if(s&ms&&zr[i-1]&s)return!0;if(s&ca){let o=new Date(Date.UTC(r,t+1,0)).getUTCDate();for(let c=e+1;c<=o;c++)if(new Date(Date.UTC(r,t,c)).getUTCDay()===a)return!1;return!0}return!1};_e.prototype.fromDate=function(r){if(this.tz!==void 0)if(typeof this.tz=="number")this.ms=r.getUTCMilliseconds(),this.second=r.getUTCSeconds(),this.minute=r.getUTCMinutes()+this.tz,this.hour=r.getUTCHours(),this.day=r.getUTCDate(),this.month=r.getUTCMonth(),this.year=r.getUTCFullYear(),this.apply();else{let t=He.toTZ(r,this.tz);this.ms=r.getMilliseconds(),this.second=t.s,this.minute=t.i,this.hour=t.h,this.day=t.d,this.month=t.m-1,this.year=t.y}else this.ms=r.getMilliseconds(),this.second=r.getSeconds(),this.minute=r.getMinutes(),this.hour=r.getHours(),this.day=r.getDate(),this.month=r.getMonth(),this.year=r.getFullYear()};_e.prototype.fromCronDate=function(r){this.tz=r.tz,this.year=r.year,this.month=r.month,this.day=r.day,this.hour=r.hour,this.minute=r.minute,this.second=r.second,this.ms=r.ms};_e.prototype.apply=function(){if(this.month>11||this.day>Gr[this.month]||this.hour>59||this.minute>59||this.second>59||this.hour<0||this.minute<0||this.second<0){let r=new Date(Date.UTC(this.year,this.month,this.day,this.hour,this.minute,this.second,this.ms));return this.ms=r.getUTCMilliseconds(),this.second=r.getUTCSeconds(),this.minute=r.getUTCMinutes(),this.hour=r.getUTCHours(),this.day=r.getUTCDate(),this.month=r.getUTCMonth(),this.year=r.getUTCFullYear(),!0}else return!1};_e.prototype.fromString=function(r){if(typeof this.tz=="number"){let t=He.fromTZISO(r);this.ms=t.getUTCMilliseconds(),this.second=t.getUTCSeconds(),this.minute=t.getUTCMinutes(),this.hour=t.getUTCHours(),this.day=t.getUTCDate(),this.month=t.getUTCMonth(),this.year=t.getUTCFullYear(),this.apply()}else return this.fromDate(He.fromTZISO(r,this.tz))};_e.prototype.findNext=function(r,t,e,s){let n=this[t],a;e.lastDayOfMonth&&(this.month!==1?a=Gr[this.month]:a=new Date(Date.UTC(this.year,this.month+1,0,0,0,0,0)).getUTCDate());let i=!e.starDOW&&t=="day"?new Date(Date.UTC(this.year,this.month,1,0,0,0,0)).getUTCDay():void 0;for(let o=this[t]+s;o1){let n=e+1;for(;n=ft.length?this:this.year>=3e3?null:this.recurse(r,t,e)};_e.prototype.increment=function(r,t,e){return this.second+=t.interval>1&&e?t.interval:1,this.ms=0,this.apply(),this.recurse(r,t,0)};_e.prototype.getDate=function(r){return r||this.tz===void 0?new Date(this.year,this.month,this.day,this.hour,this.minute,this.second,this.ms):typeof this.tz=="number"?new Date(Date.UTC(this.year,this.month,this.day,this.hour,this.minute-this.tz,this.second,this.ms)):He(this.year,this.month+1,this.day,this.hour,this.minute,this.second,this.tz)};_e.prototype.getTime=function(){return this.getDate().getTime()};function tn(r){return Object.prototype.toString.call(r)==="[object Function]"||typeof r=="function"||r instanceof Function}function Ol(r){typeof Deno<"u"&&typeof Deno.unrefTimer<"u"?Deno.unrefTimer(r):r&&typeof r.unref<"u"&&r.unref()}var qr=30*1e3,fs=[];function pe(r,t,e){if(!(this instanceof pe))return new pe(r,t,e);let s,n;if(tn(t))n=t;else if(typeof t=="object")s=t;else if(t!==void 0)throw new Error("Cron: Invalid argument passed for optionsIn. Should be one of function, or object (options).");if(tn(e))n=e;else if(typeof e=="object")s=e;else if(e!==void 0)throw new Error("Cron: Invalid argument passed for funcIn. Should be one of function, or object (options).");if(this.name=s?s.name:void 0,this.options=Fl(s),this._states={kill:!1,blocking:!1,previousRun:void 0,currentRun:void 0,once:void 0,currentTimeout:void 0,maxRuns:s?s.maxRuns:void 0,paused:s?s.paused:!1,pattern:void 0},r&&(r instanceof Date||typeof r=="string"&&r.indexOf(":")>0)?this._states.once=new _e(r,this.options.timezone||this.options.utcOffset):this._states.pattern=new Ge(r,this.options.timezone),this.name){if(fs.find(i=>i.name===this.name))throw new Error("Cron: Tried to initialize new named job '"+this.name+"', but name already taken.");fs.push(this)}return n!==void 0&&(this.fn=n,this.schedule()),this}pe.prototype.nextRun=function(r){let t=this._next(r);return t?t.getDate():null};pe.prototype.nextRuns=function(r,t){r>this._states.maxRuns&&(r=this._states.maxRuns);let e=[],s=t||this._states.currentRun;for(;r--&&(s=this.nextRun(s));)e.push(s);return e};pe.prototype.getPattern=function(){return this._states.pattern?this._states.pattern.pattern:void 0};pe.prototype.isRunning=function(){let r=this.nextRun(this._states.currentRun),t=!this._states.paused,e=this.fn!==void 0,s=!this._states.kill;return t&&e&&s&&r!==null};pe.prototype.isStopped=function(){return this._states.kill};pe.prototype.isBusy=function(){return this._states.blocking};pe.prototype.currentRun=function(){return this._states.currentRun?this._states.currentRun.getDate():null};pe.prototype.previousRun=function(){return this._states.previousRun?this._states.previousRun.getDate():null};pe.prototype.msToNext=function(r){r=r||new Date;let t=this._next(r);return t?t.getTime()-r.getTime():null};pe.prototype.stop=function(){this._states.kill=!0,this._states.currentTimeout&&clearTimeout(this._states.currentTimeout);let r=fs.indexOf(this);r>=0&&fs.splice(r,1)};pe.prototype.pause=function(){return this._states.paused=!0,!this._states.kill};pe.prototype.resume=function(){return this._states.paused=!1,!this._states.kill};pe.prototype.schedule=function(r){if(r&&this.fn)throw new Error("Cron: It is not allowed to schedule two functions using the same Croner instance.");r&&(this.fn=r);let t=this.msToNext(),e=this.nextRun(this._states.currentRun);return t==null||isNaN(t)||e===null?this:(t>qr&&(t=qr),this._states.currentTimeout=setTimeout(()=>this._checkTrigger(e),t),this._states.currentTimeout&&this.options.unref&&Ol(this._states.currentTimeout),this)};pe.prototype._trigger=async function(r){if(this._states.blocking=!0,this._states.currentRun=new _e(void 0,this.options.timezone||this.options.utcOffset),this.options.catch)try{await this.fn(this,this.options.context)}catch(t){tn(this.options.catch)&&this.options.catch(t,this)}else await this.fn(this,this.options.context);this._states.previousRun=new _e(r,this.options.timezone||this.options.utcOffset),this._states.blocking=!1};pe.prototype.trigger=async function(){await this._trigger()};pe.prototype._checkTrigger=function(r){let t=new Date,e=!this._states.paused&&t.getTime()>=r,s=this._states.blocking&&this.options.protect;e&&!s?(this._states.maxRuns--,this._trigger()):e&&s&&tn(this.options.protect)&&setTimeout(()=>this.options.protect(this),0),this.schedule()};pe.prototype._next=function(r){let t=!!(r||this._states.currentRun),e=!1;!r&&this.options.startAt&&this.options.interval&&([r,t]=this._calculatePreviousRun(r,t),e=!r),r=new _e(r,this.options.timezone||this.options.utcOffset),this.options.startAt&&r&&r.getTime()=this.options.stopAt.getTime()?null:s};pe.prototype._calculatePreviousRun=function(r,t){let e=new _e(void 0,this.options.timezone||this.options.utcOffset);if(this.options.startAt.getTime()<=e.getTime()){r=this.options.startAt;let s=r.getTime()+this.options.interval*1e3;for(;s<=e.getTime();)r=new _e(r,this.options.timezone||this.options.utcOffset).increment(this._states.pattern,this.options,!0),s=r.getTime()+this.options.interval*1e3;t=!0}return[r,t]};pe.Cron=pe;pe.scheduledJobs=fs;var wi=require("obsidian");var hi=require("crypto");var Je=require("fs"),da=require("path");function Nl(r){return`mcp__${r.replace(/[\s.]+/g,"_")}`}function sn(r,t,e={}){let s=t.permissionRules.allow.length>0||t.permissionRules.deny.length>0,n=t.permissionMode?.trim(),a=!!n&&n!=="default",i=e.mcpAllowServers??[],o=i.length>0;if(!(s||a||o))return null;let l=(0,da.join)(r,".claude"),h=(0,da.join)(l,"settings.local.json");(0,Je.existsSync)(l)||(0,Je.mkdirSync)(l,{recursive:!0});let d=null;if((0,Je.existsSync)(h))try{d=(0,Je.readFileSync)(h,"utf-8")}catch{d=null}let u={};if(a&&(u.defaultMode=n),s||o){let p=[...t.permissionRules.allow];for(let m of i)p.push(Nl(m));u.permissions={allow:p,deny:t.permissionRules.deny}}return(0,Je.writeFileSync)(h,JSON.stringify(u,null,2)+` -`,"utf-8"),{path:h,backupContent:d}}function Dt(r){if(r)try{r.backupContent!==null?(0,Je.writeFileSync)(r.path,r.backupContent,"utf-8"):(0,Je.existsSync)(r.path)&&(0,Je.unlinkSync)(r.path)}catch{}}function Vr(r){if(typeof r=="string")return r;if(Array.isArray(r))return r.map(e=>{if(typeof e=="string")return e;if(e&&typeof e=="object"&&"text"in e){let s=e.text;if(typeof s=="string")return s}return""}).filter(Boolean).join(` -`);if(r&&typeof r=="object"){for(let t of["output","result","text","message"])if(t in r)return Vr(r[t])}}function ha(r,t=[]){if(Array.isArray(r)){for(let i of r)ha(i,t);return t}if(!r||typeof r!="object")return t;let e=r,s=typeof e.tool_name=="string"&&e.tool_name||typeof e.tool=="string"&&e.tool||typeof e.name=="string"&&e.name,n=typeof e.command=="string"?e.command:typeof e.input=="string"?e.input:typeof e.cmd=="string"?e.cmd:void 0,a=typeof e.reason=="string"?e.reason:void 0;s&&["tool_use","tool","name","tool_name"].some(i=>i in e)&&t.push({tool:s,command:n,reason:a});for(let i of Object.values(e))ha(i,t);return t}function Yr(r){if(!r||typeof r!="object")return;let t=r,e=t.usage;if(e&&typeof e=="object"){let n=typeof e.input_tokens=="number"?e.input_tokens:0,a=typeof e.output_tokens=="number"?e.output_tokens:0,i=typeof e.cache_creation_input_tokens=="number"?e.cache_creation_input_tokens:0,o=typeof e.cache_read_input_tokens=="number"?e.cache_read_input_tokens:0,c=n+a+i+o;if(c>0)return c}let s=t.modelUsage;if(s&&typeof s=="object"){let n=0;for(let a of Object.values(s)){if(!a||typeof a!="object")continue;let i=a;n+=typeof i.inputTokens=="number"?i.inputTokens:0,n+=typeof i.outputTokens=="number"?i.outputTokens:0,n+=typeof i.cacheReadInputTokens=="number"?i.cacheReadInputTokens:0,n+=typeof i.cacheCreationInputTokens=="number"?i.cacheCreationInputTokens:0}if(n>0)return n}for(let n of["tokens_used","total_tokens","totalTokens"])if(typeof t[n]=="number")return t[n];for(let n of Object.values(t)){let a=Yr(n);if(typeof a=="number")return a}}function Kr(r){if(!r||typeof r!="object")return;let t=r;if(typeof t.total_cost_usd=="number")return t.total_cost_usd;for(let e of Object.values(t)){let s=Kr(e);if(typeof s=="number")return s}}function ua(r){if(!r||typeof r!="object")return;let t=r;if(t.type==="result"&&typeof t.result=="string"&&t.result.trim())return t.result}function nn(r){if(!r||typeof r!="object")return;let t=r;if(t.modelUsage&&typeof t.modelUsage=="object"){let s=Object.keys(t.modelUsage);if(s.length>0&&s[0])return s[0]}let e=t.message;if(e&&typeof e.model=="string"&&e.model)return e.model;if(typeof t.model=="string"&&t.model)return t.model;for(let s of Object.values(t)){let n=nn(s);if(n)return n}}function Bl(r){return/^gpt-|codex/i.test(r.trim())}var Jr={id:"claude-code",label:"Claude Code",cliPath(r){return r.claudeCliPath},buildExec(r){let t=["-p",r.prompt,"--output-format",r.streaming?"stream-json":"json"];r.streaming&&t.push("--verbose");let e=r.modelSource==="settings"&&Bl(r.model);r.model&&!e&&t.push("--model",r.model),r.effort&&t.push("--effort",r.effort);let s=r.agent.permissionMode?.trim();return s&&s!=="default"?t.push("--permission-mode",s):t.push("--permission-mode","bypassPermissions"),Promise.resolve({cliPath:r.settings.claudeCliPath,args:t})},parseExecOutput(r,t,e){let s=r.trim(),n;if(e){let c=ye(s);for(let l=c.length-1;l>=0;l--){let h=c[l]?.trim();if(h)try{let d=JSON.parse(h);if(d&&typeof d=="object"){n=d;break}}catch{}}}else if(s.startsWith("{")||s.startsWith("["))try{n=JSON.parse(s)}catch{n=void 0}let a=Vr(n)??"";if(!a&&e){let c=[];for(let l of ye(s)){let h=l.trim();if(h)try{let d=JSON.parse(h);if(d.type==="assistant"&&d.message?.content)for(let u of d.message.content)u.type==="text"&&u.text&&c.push(u.text);else d.type==="result"&&typeof d.result=="string"&&c.push(d.result)}catch{}}a=c.join(` -`).trim()}a||(a=t.trim()||"(no output)");let i=nn(n),o=ua(n);if((!i||!o)&&e)for(let c of ye(s)){let l=c.trim();if(l)try{let h=JSON.parse(l);if(!i){let d=nn(h);d&&(i=d)}if(!o){let d=ua(h);d&&(o=d)}if(i&&o)break}catch{}}return{outputText:a,finalResult:o,tokensUsed:Yr(n),costUsd:Kr(n),toolsUsed:ha(n),concreteModel:i,rawJson:n}},extractStreamChunk(r){let t=r.trim();if(!t)return null;let e;try{e=JSON.parse(t)}catch{return null}let s=e.type;if(s==="assistant"){let n=e.message;if(n?.content&&Array.isArray(n.content)){let a=[];for(let i of n.content)if(i.type==="text"&&typeof i.text=="string")a.push(i.text);else if(i.type==="tool_use"){let o=String(i.name??"tool"),c=i.input,l=c?.command??c?.content??"";a.push(` -\u25B8 ${o}${l?`: ${String(l).slice(0,200)}`:""} -`)}if(a.length>0)return a.join("")}}if(s==="result"){let n=typeof e.result=="string"?e.result:null;if(n)return` -${n}`}return null},setupPermissions(r,t,e,s){let n=sn(r,t,{mcpAllowServers:s?.mcpAllowServers});return n?Promise.resolve({restore:()=>Dt(n)}):Promise.resolve(null)}};var le=require("fs"),fa=require("crypto"),ys=require("os"),Xe=require("path");var Ul=/^[A-Z][A-Za-z]*$/;function $l(r){let t=r.trim();if(!t)return{error:"empty Bash() matches every command \u2014 use the read-only sandbox instead"};let e=t.split(/\s+/).filter(Boolean),s=[];for(let n=0;nJSON.stringify(s)).join(", ")}], decision="${t}", justification="agent-fleet")`}function Wl(r){let t=[],e=[],s=(a,i)=>{let o=a.trim();if(!o||o.startsWith("mcp__"))return;let c=o.match(/^Bash\((.*)\)$/s);if(!c){e.push({rule:o,reason:Ul.test(o)?"tool-name rules are governed by the sandbox, not execpolicy":"not a Bash(...) command pattern"});return}let l=$l(c[1]);if("error"in l){e.push({rule:o,reason:l.error});return}t.push(jl(l.tokens,i))};for(let a of r.permissionRules.deny)s(a,"forbidden");for(let a of r.permissionRules.allow)s(a,"allow");return t.length===0?{rulesText:null,dropped:e,emitted:0}:{rulesText:`${`# Generated by Agent Fleet for agent "${r.name}". Do not edit \u2014 regenerated each run. +Your scope's inbox, topics, index, and log are NOT deleted.`,confirmText:"Delete",danger:!0,onConfirm:async()=>{try{await Ui(this.plugin.app,this.plugin.settings.fleetFolder,o.name),await this.plugin.refreshFromVault(),new D.Notice(`Wiki Keeper "${o.name}" deleted.`),this.scheduleRerender()}catch(m){let f=m instanceof Error?m.message:String(m);new D.Notice(`Failed to delete: ${f}`)}}}).open()}}new D.Setting(t).setName("Add Wiki Keeper").setDesc("Create a new scoped wiki agent. All fields optional.").addButton(o=>o.setButtonText("+ Add").onClick(()=>{new Xa(this.plugin,()=>{this.scheduleRerender()}).open()}))}scheduleRerender(){window.setTimeout(()=>{(async()=>{try{await this.plugin.refreshFromVault()}catch{}this.display()})()},600)}},Xa=class extends D.Modal{constructor(t,s){super(t.app);this.plugin=t;this.onCreated=s;this.input=Ya()}input;onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("af-wk-modal"),t.createEl("h2",{text:"Add Wiki Keeper"});let s=t.createDiv({cls:"af-wk-banner"});s.createEl("strong",{text:"All fields are optional. "}),s.createSpan({text:"Click Create with everything blank and you'll get a whole-vault keeper with sensible defaults (inbox at _sources/inbox, topics at topics/, nightly ingest at 3am, Sunday lint)."}),t.createEl("h3",{text:"Scope",cls:"af-wk-section-h"}),new D.Setting(t).setName("Scope folder").setDesc("Optional. Vault-relative path. Empty = whole vault.").addText(d=>d.setPlaceholder("(empty = whole vault)").setValue(this.input.scopeRoot).onChange(u=>{this.input.scopeRoot=u.trim(),this.renderPreview()})),new D.Setting(t).setName("Slug").setDesc("Optional. Agent name suffix. Default: derived from scope folder name, or 'wiki-keeper' for whole-vault.").addText(d=>d.setPlaceholder("(auto)").setValue(this.input.scopeSlug).onChange(u=>{this.input.scopeSlug=u.trim(),this.renderPreview()})),this.renderPreview(),t.createEl("h3",{text:"Sources",cls:"af-wk-section-h"}),new D.Setting(t).setName("Watched folders").setDesc("Optional. Comma- or newline-separated vault-relative paths. Read-only \u2014 claims are extracted but files are never moved. Leave empty for inbox-only mode (you drop files into _sources/inbox/ and they get archived after processing).").addTextArea(d=>d.setPlaceholder("(empty = inbox-only)").setValue(this.input.watchedFolders.join(", ")).onChange(u=>{this.input.watchedFolders=u.split(/[,\n]/).map(h=>h.trim()).filter(Boolean)})),new D.Setting(t).setName("Exclude patterns").setDesc("Optional. Glob patterns to skip. Leave empty to process everything under watched + inbox.").addTextArea(d=>d.setPlaceholder("(empty = no excludes)").setValue(this.input.excludePatterns.join(", ")).onChange(u=>{this.input.excludePatterns=u.split(/[,\n]/).map(h=>h.trim()).filter(Boolean)})),new D.Setting(t).setName("Watch files modified since").setDesc("Watched mode only \u2014 files whose last modification date is before this are skipped. Defaults to today so an established vault doesn't flood the keeper on first run. Clear the field to process everything.").addText(d=>{d.inputEl.type="date",d.setValue(this.input.watchedSince).onChange(u=>{this.input.watchedSince=u.trim()})});let n=this.plugin.runtime.getSnapshot().channels.map(d=>d.name);new D.Setting(t).setName("Heartbeat channel").setDesc("Optional. Slack/Telegram channel for ingest + lint summaries. Leave as (none) to disable.").addDropdown(d=>{d.addOption("","(none)");for(let u of n)d.addOption(u,u);d.setValue(this.input.heartbeatChannel).onChange(u=>{this.input.heartbeatChannel=u})}),t.createEl("h3",{text:"Advanced",cls:"af-wk-section-h"}),new D.Setting(t).setName("Max tokens per ingest").setDesc("Hard cap on token spend per run. Unprocessed files resume next cycle.").addText(d=>d.setValue(String(this.input.maxTokensPerIngest)).onChange(u=>{let h=parseInt(u,10);!isNaN(h)&&h>0&&(this.input.maxTokensPerIngest=h)})),new D.Setting(t).setName("File substantive answers").setDesc("If on, wiki-query files long answers under _topics/syntheses/. Off = answers live only in chat.").addToggle(d=>d.setValue(this.input.fileSubstantiveAnswers).onChange(u=>{this.input.fileSubstantiveAnswers=u})),new D.Setting(t).setName("Obsidian URL scheme for citations").setDesc("If on, external-channel (Slack/Telegram) replies rewrite [[wikilinks]] as obsidian:// URLs.").addToggle(d=>d.setValue(this.input.obsidianUrlScheme).onChange(u=>{this.input.obsidianUrlScheme=u}));let r=t.createEl("h3",{text:"Paths (expert)",cls:"af-wk-section-h"});r.title="Paths are relative to scope_root. Defaults work for almost everyone. Override only if your vault layout demands it \u2014 these cannot be changed after creation.";let i=(d,u,h)=>{new D.Setting(t).setName(d).setDesc(u).addText(p=>p.setPlaceholder(String(this.input[h]??"")).setValue(String(this.input[h]??"")).onChange(m=>{this.input[h]=m.trim()||Fi[h]}))};i("Inbox path","Where one-off source drops land.","inboxPath"),i("Archive path","Where processed inbox files are moved after summarization.","archivePath"),i("Topics root","Folder under scope_root that holds the generated topic pages.","topicsRoot"),i("Index path","Relative file path for the curated index.","indexPath"),i("Log path","Relative file path for the keeper's activity log.","logPath"),i("State file","Hidden JSON file that tracks watched-source mtimes.","stateFile");let o=t.createDiv({cls:"af-wk-modal-footer"}),l=o.createEl("button",{text:"Cancel"});l.onclick=()=>this.close();let c=o.createEl("button",{text:"Create",cls:"mod-cta"});c.onclick=async()=>{c.setText("Creating\u2026"),c.disabled=!0;try{let{name:d}=await Ni(this.app.vault,this.plugin.settings.fleetFolder,this.input);await this.plugin.refreshFromVault(),this.close(),new D.Notice(`Wiki Keeper "${d}" created.`),this.onCreated()}catch(d){let u=d instanceof Error?d.message:String(d);new D.Notice(`Failed to create Wiki Keeper: ${u}`),c.setText("Create"),c.disabled=!1}}}renderPreview(){let t=this.contentEl.querySelector(".af-wk-name-preview");t||(t=this.contentEl.createDiv({cls:"af-wk-name-preview"}));let s=this.input.scopeSlug||this.input.scopeRoot;t.setText(`\u2192 Will create agent: ${Ka(s)}`)}onClose(){this.contentEl.empty()}},Qa=class extends D.Modal{constructor(t,s,n){super(t.app);this.plugin=t;this.agent=s;this.onSaved=n;let r=s.wikiKeeper;this.edit={watchedFolders:[...r.watchedFolders],excludePatterns:[...r.excludePatterns],watchedSince:r.watchedSince,heartbeatChannel:s.heartbeatChannel??"",fileSubstantiveAnswers:r.fileSubstantiveAnswers,obsidianUrlScheme:r.obsidianUrlScheme,maxTokensPerIngest:r.maxTokensPerIngest,maxTokensPerRefresh:r.maxTokensPerRefresh,indexSplitThreshold:r.indexSplitThreshold,dedupSimilarityThreshold:r.dedupSimilarityThreshold,summaryStaleDays:r.summaryStaleDays}}edit;onOpen(){let{contentEl:t}=this;t.empty(),t.addClass("af-wk-modal"),t.createEl("h2",{text:`Edit ${this.agent.name}`});let s=t.createDiv({cls:"af-wk-banner"}),n=this.agent.wikiKeeper.scopeRoot||"(whole vault)";s.createEl("strong",{text:"Scope: "}),s.createSpan({text:n}),s.createEl("br"),s.createSpan({text:"Scope and paths are fixed after creation \u2014 changing them would orphan existing topic pages. To move a Wiki Keeper, delete this one and create a new one at the new scope."}),t.createEl("h3",{text:"Sources",cls:"af-wk-section-h"}),new D.Setting(t).setName("Watched folders").setDesc("Comma- or newline-separated vault-relative paths.").addTextArea(c=>c.setValue(this.edit.watchedFolders.join(", ")).onChange(d=>{this.edit.watchedFolders=d.split(/[,\n]/).map(u=>u.trim()).filter(Boolean)})),new D.Setting(t).setName("Exclude patterns").setDesc("Glob patterns to skip.").addTextArea(c=>c.setValue(this.edit.excludePatterns.join(", ")).onChange(d=>{this.edit.excludePatterns=d.split(/[,\n]/).map(u=>u.trim()).filter(Boolean)})),new D.Setting(t).setName("Watch files modified since").setDesc("Watched mode skips files older than this date. Clear to process everything.").addText(c=>{c.inputEl.type="date",c.setValue(this.edit.watchedSince).onChange(d=>{this.edit.watchedSince=d.trim()})});let r=this.plugin.runtime.getSnapshot().channels.map(c=>c.name);new D.Setting(t).setName("Heartbeat channel").addDropdown(c=>{c.addOption("","(none)");for(let d of r)c.addOption(d,d);c.setValue(this.edit.heartbeatChannel).onChange(d=>{this.edit.heartbeatChannel=d})}),t.createEl("h3",{text:"Advanced",cls:"af-wk-section-h"}),new D.Setting(t).setName("Max tokens per ingest").addText(c=>c.setValue(String(this.edit.maxTokensPerIngest)).onChange(d=>{let u=parseInt(d,10);!isNaN(u)&&u>0&&(this.edit.maxTokensPerIngest=u)})),new D.Setting(t).setName("Max tokens per refresh").setDesc("Separate budget for the synthesis-refresh phase that regenerates topic-page summaries.").addText(c=>c.setValue(String(this.edit.maxTokensPerRefresh)).onChange(d=>{let u=parseInt(d,10);!isNaN(u)&&u>0&&(this.edit.maxTokensPerRefresh=u)})),new D.Setting(t).setName("Index split threshold").setDesc("Topic-page count above which index.md splits into per-type sub-MOCs.").addText(c=>c.setValue(String(this.edit.indexSplitThreshold)).onChange(d=>{let u=parseInt(d,10);!isNaN(u)&&u>0&&(this.edit.indexSplitThreshold=u)})),new D.Setting(t).setName("Summary stale (days)").setDesc("Lint flags topic-page summaries older than this and chains wiki-refresh.").addText(c=>c.setValue(String(this.edit.summaryStaleDays)).onChange(d=>{let u=parseInt(d,10);!isNaN(u)&&u>0&&(this.edit.summaryStaleDays=u)})),new D.Setting(t).setName("Dedup similarity threshold").setDesc("Levenshtein-ratio above which lint proposes merging two same-type pages (0.0\u20131.0).").addText(c=>c.setValue(String(this.edit.dedupSimilarityThreshold)).onChange(d=>{let u=parseFloat(d);!isNaN(u)&&u>0&&u<=1&&(this.edit.dedupSimilarityThreshold=u)})),new D.Setting(t).setName("File substantive answers").setDesc("Compounding: wiki-query files long answers under _topics/syntheses/ and bullets each cited topic page. Default ON.").addToggle(c=>c.setValue(this.edit.fileSubstantiveAnswers).onChange(d=>{this.edit.fileSubstantiveAnswers=d})),new D.Setting(t).setName("Obsidian URL scheme for citations").setDesc("Rewrite [[wikilinks]] as obsidian:// URLs when replying via Slack/Telegram.").addToggle(c=>c.setValue(this.edit.obsidianUrlScheme).onChange(d=>{this.edit.obsidianUrlScheme=d}));let i=t.createDiv({cls:"af-wk-modal-footer"}),o=i.createEl("button",{text:"Cancel"});o.onclick=()=>this.close();let l=i.createEl("button",{text:"Save",cls:"mod-cta"});l.onclick=async()=>{l.setText("Saving\u2026"),l.disabled=!0;try{await Bi(this.app.vault,this.plugin.settings.fleetFolder,this.agent.name,this.edit),await this.plugin.refreshFromVault(),this.close(),new D.Notice("Saved."),this.onSaved()}catch(c){let d=c instanceof Error?c.message:String(c);new D.Notice(`Failed to save: ${d}`),l.setText("Save"),l.disabled=!1}}}onClose(){this.contentEl.empty()}};function ud(a){return a.type==="slack",a.botToken}function hd(a){return a.length<=10?"***":`${a.slice(0,6)}\u2026${a.slice(-4)}`}var mr=require("crypto");function Ge(a,e,t,s,n,r,i,o){return Ge.fromTZ(Ge.tp(a,e,t,s,n,r,i),o)}Ge.fromTZISO=(a,e,t)=>Ge.fromTZ(pd(a,e),t);Ge.fromTZ=function(a,e){let t=new Date(Date.UTC(a.y,a.m-1,a.d,a.h,a.i,a.s)),s=Za(a.tz,t),n=new Date(t.getTime()-s),r=Za(a.tz,n);if(r-s===0)return n;{let i=new Date(t.getTime()-r),o=Za(a.tz,i);if(o-r===0)return i;if(!e&&o-r>0)return i;if(e)throw new Error("Invalid date passed to fromTZ()");return n}};Ge.toTZ=function(a,e){let t=a.toLocaleString("en-US",{timeZone:e}).replace(/[\u202f]/," "),s=new Date(t);return{y:s.getFullYear(),m:s.getMonth()+1,d:s.getDate(),h:s.getHours(),i:s.getMinutes(),s:s.getSeconds(),tz:e}};Ge.tp=(a,e,t,s,n,r,i)=>({y:a,m:e,d:t,h:s,i:n,s:r,tz:i});function Za(a,e=new Date){let t=e.toLocaleString("en-US",{timeZone:a,timeZoneName:"shortOffset"}).split(" ").slice(-1)[0],s=e.toLocaleString("en-US").replace(/[\u202f]/," ");return Date.parse(`${s} GMT`)-Date.parse(`${s} ${t}`)}function pd(a,e){let t=new Date(Date.parse(a));if(isNaN(t))throw new Error("minitz: Invalid ISO8601 passed to parser.");let s=a.substring(9);return a.includes("Z")||s.includes("-")||s.includes("+")?Ge.tp(t.getUTCFullYear(),t.getUTCMonth()+1,t.getUTCDate(),t.getUTCHours(),t.getUTCMinutes(),t.getUTCSeconds(),"Etc/UTC"):Ge.tp(t.getFullYear(),t.getMonth()+1,t.getDate(),t.getHours(),t.getMinutes(),t.getSeconds(),e)}Ge.minitz=Ge;function md(a){if(a===void 0&&(a={}),delete a.name,a.legacyMode=a.legacyMode===void 0?!0:a.legacyMode,a.paused=a.paused===void 0?!1:a.paused,a.maxRuns=a.maxRuns===void 0?1/0:a.maxRuns,a.catch=a.catch===void 0?!1:a.catch,a.interval=a.interval===void 0?0:parseInt(a.interval,10),a.utcOffset=a.utcOffset===void 0?void 0:parseInt(a.utcOffset,10),a.unref=a.unref===void 0?!1:a.unref,a.startAt&&(a.startAt=new Te(a.startAt,a.timezone)),a.stopAt&&(a.stopAt=new Te(a.stopAt,a.timezone)),a.interval!==null){if(isNaN(a.interval))throw new Error("CronOptions: Supplied value for interval is not a number");if(a.interval<0)throw new Error("CronOptions: Supplied value for interval can not be negative")}if(a.utcOffset!==void 0){if(isNaN(a.utcOffset))throw new Error("CronOptions: Invalid value passed for utcOffset, should be number representing minutes offset from UTC.");if(a.utcOffset<-870||a.utcOffset>870)throw new Error("CronOptions: utcOffset out of bounds.");if(a.utcOffset!==void 0&&a.timezone)throw new Error("CronOptions: Combining 'utcOffset' with 'timezone' is not allowed.")}if(a.unref!==!0&&a.unref!==!1)throw new Error("CronOptions: Unref should be either true, false or undefined(false).");return a}var er=32,$s=31|er,ji=[1,2,4,8,16];function Je(a,e){this.pattern=a,this.timezone=e,this.second=Array(60).fill(0),this.minute=Array(60).fill(0),this.hour=Array(24).fill(0),this.day=Array(31).fill(0),this.month=Array(12).fill(0),this.dayOfWeek=Array(7).fill(0),this.lastDayOfMonth=!1,this.starDOM=!1,this.starDOW=!1,this.parse()}Je.prototype.parse=function(){if(!(typeof this.pattern=="string"||this.pattern.constructor===String))throw new TypeError("CronPattern: Pattern has to be of type string.");this.pattern.indexOf("@")>=0&&(this.pattern=this.handleNicknames(this.pattern).trim());let a=this.pattern.replace(/\s+/g," ").split(" ");if(a.length<5||a.length>6)throw new TypeError("CronPattern: invalid configuration format ('"+this.pattern+"'), exactly five or six space separated parts are required.");if(a.length===5&&a.unshift("0"),a[3].indexOf("L")>=0&&(a[3]=a[3].replace("L",""),this.lastDayOfMonth=!0),a[3]=="*"&&(this.starDOM=!0),a[4].length>=3&&(a[4]=this.replaceAlphaMonths(a[4])),a[5].length>=3&&(a[5]=this.replaceAlphaDays(a[5])),a[5]=="*"&&(this.starDOW=!0),this.pattern.indexOf("?")>=0){let e=new Te(new Date,this.timezone).getDate(!0);a[0]=a[0].replace("?",e.getSeconds()),a[1]=a[1].replace("?",e.getMinutes()),a[2]=a[2].replace("?",e.getHours()),this.starDOM||(a[3]=a[3].replace("?",e.getDate())),a[4]=a[4].replace("?",e.getMonth()+1),this.starDOW||(a[5]=a[5].replace("?",e.getDay()))}this.throwAtIllegalCharacters(a),this.partToArray("second",a[0],0,1),this.partToArray("minute",a[1],0,1),this.partToArray("hour",a[2],0,1),this.partToArray("day",a[3],-1,1),this.partToArray("month",a[4],-1,1),this.partToArray("dayOfWeek",a[5],0,$s),this.dayOfWeek[7]&&(this.dayOfWeek[0]=this.dayOfWeek[7])};Je.prototype.partToArray=function(a,e,t,s){let n=this[a],r=a==="day"&&this.lastDayOfMonth;if(e===""&&!r)throw new TypeError("CronPattern: configuration entry "+a+" ("+e+") is empty, check for trailing spaces.");if(e==="*")return n.fill(s);let i=e.split(",");if(i.length>1)for(let o=0;o6)&&e!=="L")throw new RangeError("CronPattern: Invalid value for dayOfWeek: "+e);this.setNthWeekdayOfMonth(e,t);return}if(a==="second"||a==="minute"){if(e<0||e>=60)throw new RangeError("CronPattern: Invalid value for "+a+": "+e)}else if(a==="hour"){if(e<0||e>=24)throw new RangeError("CronPattern: Invalid value for "+a+": "+e)}else if(a==="day"){if(e<0||e>=31)throw new RangeError("CronPattern: Invalid value for "+a+": "+e)}else if(a==="month"&&(e<0||e>=12))throw new RangeError("CronPattern: Invalid value for "+a+": "+e);this[a][e]=t};Je.prototype.handleRangeWithStepping=function(a,e,t,s){let n=this.extractNth(a,e),r=n[0].match(/^(\d+)-(\d+)\/(\d+)$/);if(r===null)throw new TypeError("CronPattern: Syntax error, illegal range with stepping: '"+a+"'");let[,i,o,l]=r;if(i=parseInt(i,10)+t,o=parseInt(o,10)+t,l=parseInt(l,10),isNaN(i))throw new TypeError("CronPattern: Syntax error, illegal lower range (NaN)");if(isNaN(o))throw new TypeError("CronPattern: Syntax error, illegal upper range (NaN)");if(isNaN(l))throw new TypeError("CronPattern: Syntax error, illegal stepping: (NaN)");if(l===0)throw new TypeError("CronPattern: Syntax error, illegal stepping: 0");if(l>this[e].length)throw new TypeError("CronPattern: Syntax error, steps cannot be greater than maximum value of part ("+this[e].length+")");if(i>o)throw new TypeError("CronPattern: From value is larger than to value: '"+a+"'");for(let c=i;c<=o;c+=l)this.setPart(e,c,n[1]||s)};Je.prototype.extractNth=function(a,e){let t=a,s;if(t.includes("#")){if(e!=="dayOfWeek")throw new Error("CronPattern: nth (#) only allowed in day-of-week field");s=t.split("#")[1],t=t.split("#")[0]}return[t,s]};Je.prototype.handleRange=function(a,e,t,s){let n=this.extractNth(a,e),r=n[0].split("-");if(r.length!==2)throw new TypeError("CronPattern: Syntax error, illegal range: '"+a+"'");let i=parseInt(r[0],10)+t,o=parseInt(r[1],10)+t;if(isNaN(i))throw new TypeError("CronPattern: Syntax error, illegal lower range (NaN)");if(isNaN(o))throw new TypeError("CronPattern: Syntax error, illegal upper range (NaN)");if(i>o)throw new TypeError("CronPattern: From value is larger than to value: '"+a+"'");for(let l=i;l<=o;l++)this.setPart(e,l,n[1]||s)};Je.prototype.handleStepping=function(a,e,t,s){let n=this.extractNth(a,e),r=n[0].split("/");if(r.length!==2)throw new TypeError("CronPattern: Syntax error, illegal stepping: '"+a+"'");let i=0;r[0]!=="*"&&(i=parseInt(r[0],10)+t);let o=parseInt(r[1],10);if(isNaN(o))throw new TypeError("CronPattern: Syntax error, illegal stepping: (NaN)");if(o===0)throw new TypeError("CronPattern: Syntax error, illegal stepping: 0");if(o>this[e].length)throw new TypeError("CronPattern: Syntax error, max steps for part is ("+this[e].length+")");for(let l=i;l0)this.dayOfWeek[a]=this.dayOfWeek[a]|ji[e-1];else if(e===$s)this.dayOfWeek[a]=$s;else throw new TypeError(`CronPattern: nth weekday of of range, should be 1-5 or L. Value: ${e}`)};var Hi=[31,28,31,30,31,30,31,31,30,31,30,31],Dt=[["month","year",0],["day","month",-1],["hour","day",0],["minute","hour",0],["second","minute",0]];function Te(a,e){if(this.tz=e,a&&a instanceof Date)if(!isNaN(a))this.fromDate(a);else throw new TypeError("CronDate: Invalid date passed to CronDate constructor");else if(a===void 0)this.fromDate(new Date);else if(a&&typeof a=="string")this.fromString(a);else if(a instanceof Te)this.fromCronDate(a);else throw new TypeError("CronDate: Invalid type ("+typeof a+") passed to CronDate constructor")}Te.prototype.isNthWeekdayOfMonth=function(a,e,t,s){let r=new Date(Date.UTC(a,e,t)).getUTCDay(),i=0;for(let o=1;o<=t;o++)new Date(Date.UTC(a,e,o)).getUTCDay()===r&&i++;if(s&$s&&ji[i-1]&s)return!0;if(s&er){let o=new Date(Date.UTC(a,e+1,0)).getUTCDate();for(let l=t+1;l<=o;l++)if(new Date(Date.UTC(a,e,l)).getUTCDay()===r)return!1;return!0}return!1};Te.prototype.fromDate=function(a){if(this.tz!==void 0)if(typeof this.tz=="number")this.ms=a.getUTCMilliseconds(),this.second=a.getUTCSeconds(),this.minute=a.getUTCMinutes()+this.tz,this.hour=a.getUTCHours(),this.day=a.getUTCDate(),this.month=a.getUTCMonth(),this.year=a.getUTCFullYear(),this.apply();else{let e=Ge.toTZ(a,this.tz);this.ms=a.getMilliseconds(),this.second=e.s,this.minute=e.i,this.hour=e.h,this.day=e.d,this.month=e.m-1,this.year=e.y}else this.ms=a.getMilliseconds(),this.second=a.getSeconds(),this.minute=a.getMinutes(),this.hour=a.getHours(),this.day=a.getDate(),this.month=a.getMonth(),this.year=a.getFullYear()};Te.prototype.fromCronDate=function(a){this.tz=a.tz,this.year=a.year,this.month=a.month,this.day=a.day,this.hour=a.hour,this.minute=a.minute,this.second=a.second,this.ms=a.ms};Te.prototype.apply=function(){if(this.month>11||this.day>Hi[this.month]||this.hour>59||this.minute>59||this.second>59||this.hour<0||this.minute<0||this.second<0){let a=new Date(Date.UTC(this.year,this.month,this.day,this.hour,this.minute,this.second,this.ms));return this.ms=a.getUTCMilliseconds(),this.second=a.getUTCSeconds(),this.minute=a.getUTCMinutes(),this.hour=a.getUTCHours(),this.day=a.getUTCDate(),this.month=a.getUTCMonth(),this.year=a.getUTCFullYear(),!0}else return!1};Te.prototype.fromString=function(a){if(typeof this.tz=="number"){let e=Ge.fromTZISO(a);this.ms=e.getUTCMilliseconds(),this.second=e.getUTCSeconds(),this.minute=e.getUTCMinutes(),this.hour=e.getUTCHours(),this.day=e.getUTCDate(),this.month=e.getUTCMonth(),this.year=e.getUTCFullYear(),this.apply()}else return this.fromDate(Ge.fromTZISO(a,this.tz))};Te.prototype.findNext=function(a,e,t,s){let n=this[e],r;t.lastDayOfMonth&&(this.month!==1?r=Hi[this.month]:r=new Date(Date.UTC(this.year,this.month+1,0,0,0,0,0)).getUTCDate());let i=!t.starDOW&&e=="day"?new Date(Date.UTC(this.year,this.month,1,0,0,0,0)).getUTCDay():void 0;for(let o=this[e]+s;o1){let n=t+1;for(;n=Dt.length?this:this.year>=3e3?null:this.recurse(a,e,t)};Te.prototype.increment=function(a,e,t){return this.second+=e.interval>1&&t?e.interval:1,this.ms=0,this.apply(),this.recurse(a,e,0)};Te.prototype.getDate=function(a){return a||this.tz===void 0?new Date(this.year,this.month,this.day,this.hour,this.minute,this.second,this.ms):typeof this.tz=="number"?new Date(Date.UTC(this.year,this.month,this.day,this.hour,this.minute-this.tz,this.second,this.ms)):Ge(this.year,this.month+1,this.day,this.hour,this.minute,this.second,this.tz)};Te.prototype.getTime=function(){return this.getDate().getTime()};function $n(a){return Object.prototype.toString.call(a)==="[object Function]"||typeof a=="function"||a instanceof Function}function fd(a){typeof Deno<"u"&&typeof Deno.unrefTimer<"u"?Deno.unrefTimer(a):a&&typeof a.unref<"u"&&a.unref()}var $i=30*1e3,js=[];function ie(a,e,t){if(!(this instanceof ie))return new ie(a,e,t);let s,n;if($n(e))n=e;else if(typeof e=="object")s=e;else if(e!==void 0)throw new Error("Cron: Invalid argument passed for optionsIn. Should be one of function, or object (options).");if($n(t))n=t;else if(typeof t=="object")s=t;else if(t!==void 0)throw new Error("Cron: Invalid argument passed for funcIn. Should be one of function, or object (options).");if(this.name=s?s.name:void 0,this.options=md(s),this._states={kill:!1,blocking:!1,previousRun:void 0,currentRun:void 0,once:void 0,currentTimeout:void 0,maxRuns:s?s.maxRuns:void 0,paused:s?s.paused:!1,pattern:void 0},a&&(a instanceof Date||typeof a=="string"&&a.indexOf(":")>0)?this._states.once=new Te(a,this.options.timezone||this.options.utcOffset):this._states.pattern=new Je(a,this.options.timezone),this.name){if(js.find(i=>i.name===this.name))throw new Error("Cron: Tried to initialize new named job '"+this.name+"', but name already taken.");js.push(this)}return n!==void 0&&(this.fn=n,this.schedule()),this}ie.prototype.nextRun=function(a){let e=this._next(a);return e?e.getDate():null};ie.prototype.nextRuns=function(a,e){a>this._states.maxRuns&&(a=this._states.maxRuns);let t=[],s=e||this._states.currentRun;for(;a--&&(s=this.nextRun(s));)t.push(s);return t};ie.prototype.getPattern=function(){return this._states.pattern?this._states.pattern.pattern:void 0};ie.prototype.isRunning=function(){let a=this.nextRun(this._states.currentRun),e=!this._states.paused,t=this.fn!==void 0,s=!this._states.kill;return e&&t&&s&&a!==null};ie.prototype.isStopped=function(){return this._states.kill};ie.prototype.isBusy=function(){return this._states.blocking};ie.prototype.currentRun=function(){return this._states.currentRun?this._states.currentRun.getDate():null};ie.prototype.previousRun=function(){return this._states.previousRun?this._states.previousRun.getDate():null};ie.prototype.msToNext=function(a){a=a||new Date;let e=this._next(a);return e?e.getTime()-a.getTime():null};ie.prototype.stop=function(){this._states.kill=!0,this._states.currentTimeout&&clearTimeout(this._states.currentTimeout);let a=js.indexOf(this);a>=0&&js.splice(a,1)};ie.prototype.pause=function(){return this._states.paused=!0,!this._states.kill};ie.prototype.resume=function(){return this._states.paused=!1,!this._states.kill};ie.prototype.schedule=function(a){if(a&&this.fn)throw new Error("Cron: It is not allowed to schedule two functions using the same Croner instance.");a&&(this.fn=a);let e=this.msToNext(),t=this.nextRun(this._states.currentRun);return e==null||isNaN(e)||t===null?this:(e>$i&&(e=$i),this._states.currentTimeout=setTimeout(()=>this._checkTrigger(t),e),this._states.currentTimeout&&this.options.unref&&fd(this._states.currentTimeout),this)};ie.prototype._trigger=async function(a){if(this._states.blocking=!0,this._states.currentRun=new Te(void 0,this.options.timezone||this.options.utcOffset),this.options.catch)try{await this.fn(this,this.options.context)}catch(e){$n(this.options.catch)&&this.options.catch(e,this)}else await this.fn(this,this.options.context);this._states.previousRun=new Te(a,this.options.timezone||this.options.utcOffset),this._states.blocking=!1};ie.prototype.trigger=async function(){await this._trigger()};ie.prototype._checkTrigger=function(a){let e=new Date,t=!this._states.paused&&e.getTime()>=a,s=this._states.blocking&&this.options.protect;t&&!s?(this._states.maxRuns--,this._trigger()):t&&s&&$n(this.options.protect)&&setTimeout(()=>this.options.protect(this),0),this.schedule()};ie.prototype._next=function(a){let e=!!(a||this._states.currentRun),t=!1;!a&&this.options.startAt&&this.options.interval&&([a,e]=this._calculatePreviousRun(a,e),t=!a),a=new Te(a,this.options.timezone||this.options.utcOffset),this.options.startAt&&a&&a.getTime()=this.options.stopAt.getTime()?null:s};ie.prototype._calculatePreviousRun=function(a,e){let t=new Te(void 0,this.options.timezone||this.options.utcOffset);if(this.options.startAt.getTime()<=t.getTime()){a=this.options.startAt;let s=a.getTime()+this.options.interval*1e3;for(;s<=t.getTime();)a=new Te(a,this.options.timezone||this.options.utcOffset).increment(this._states.pattern,this.options,!0),s=a.getTime()+this.options.interval*1e3;e=!0}return[a,e]};ie.Cron=ie;ie.scheduledJobs=js;var xo=require("obsidian");var mo=require("crypto");var rt=require("fs"),tr=require("path");function gd(a){return`mcp__${a.replace(/[\s.]+/g,"_")}`}function jn(a,e,t={}){let s=e.permissionRules.allow.length>0||e.permissionRules.deny.length>0,n=e.permissionMode?.trim(),r=!!n&&n!=="default",i=t.mcpAllowServers??[],o=i.length>0;if(!(s||r||o))return null;let c=(0,tr.join)(a,".claude"),d=(0,tr.join)(c,"settings.local.json");(0,rt.existsSync)(c)||(0,rt.mkdirSync)(c,{recursive:!0});let u=null;if((0,rt.existsSync)(d))try{u=(0,rt.readFileSync)(d,"utf-8")}catch{u=null}let h={};if(r&&(h.defaultMode=n),s||o){let p=[...e.permissionRules.allow];for(let m of i)p.push(gd(m));h.permissions={allow:p,deny:e.permissionRules.deny}}return(0,rt.writeFileSync)(d,JSON.stringify(h,null,2)+` +`,"utf-8"),{path:d,backupContent:u}}function Zt(a){if(a){if(a.backupContent!==null)try{(0,rt.writeFileSync)(a.path,a.backupContent,"utf-8");return}catch(e){console.warn(`Agent Fleet: failed to restore the previous ${a.path} (${e instanceof Error?e.message:String(e)}); deleting the temporary agent settings file instead so it doesn't affect later runs.`)}try{(0,rt.existsSync)(a.path)&&(0,rt.unlinkSync)(a.path)}catch(e){console.warn(`Agent Fleet: failed to remove the temporary agent settings file at ${a.path} (${e instanceof Error?e.message:String(e)}). Stale agent permissions may leak into the next Claude run in this directory \u2014 delete it manually.`)}}}function es(a){try{return JSON.parse(a)}catch{return}}var Wi=200;function Hs(a,e,t){let s=e.length>Wi?`${e.slice(0,Wi)}\u2026`:e,n=t instanceof Error?t.message:t!==void 0?String(t):"no parseable JSON found";console.warn(`Agent Fleet: ${a} \u2014 ${n}. Output begins: ${s}`)}function qi(a,e){try{return JSON.parse(e)}catch(t){Hs(a,e,t);return}}function zi(a){if(typeof a=="string")return a;if(Array.isArray(a))return a.map(t=>{if(typeof t=="string")return t;if(t&&typeof t=="object"&&"text"in t){let s=t.text;if(typeof s=="string")return s}return""}).filter(Boolean).join(` +`);if(a&&typeof a=="object"){for(let e of["output","result","text","message"])if(e in a)return zi(a[e])}}function sr(a,e=[]){if(Array.isArray(a)){for(let i of a)sr(i,e);return e}if(!a||typeof a!="object")return e;let t=a,s=typeof t.tool_name=="string"&&t.tool_name||typeof t.tool=="string"&&t.tool||typeof t.name=="string"&&t.name,n=typeof t.command=="string"?t.command:typeof t.input=="string"?t.input:typeof t.cmd=="string"?t.cmd:void 0,r=typeof t.reason=="string"?t.reason:void 0;s&&["tool_use","tool","name","tool_name"].some(i=>i in t)&&e.push({tool:s,command:n,reason:r});for(let i of Object.values(t))sr(i,e);return e}function Gi(a){if(!a||typeof a!="object")return;let e=a,t=e.usage;if(t&&typeof t=="object"){let n=typeof t.input_tokens=="number"?t.input_tokens:0,r=typeof t.output_tokens=="number"?t.output_tokens:0,i=typeof t.cache_creation_input_tokens=="number"?t.cache_creation_input_tokens:0,o=typeof t.cache_read_input_tokens=="number"?t.cache_read_input_tokens:0,l=n+r+i+o;if(l>0)return l}let s=e.modelUsage;if(s&&typeof s=="object"){let n=0;for(let r of Object.values(s)){if(!r||typeof r!="object")continue;let i=r;n+=typeof i.inputTokens=="number"?i.inputTokens:0,n+=typeof i.outputTokens=="number"?i.outputTokens:0,n+=typeof i.cacheReadInputTokens=="number"?i.cacheReadInputTokens:0,n+=typeof i.cacheCreationInputTokens=="number"?i.cacheCreationInputTokens:0}if(n>0)return n}for(let n of["tokens_used","total_tokens","totalTokens"])if(typeof e[n]=="number")return e[n];for(let n of Object.values(e)){let r=Gi(n);if(typeof r=="number")return r}}function Vi(a){if(!a||typeof a!="object")return;let e=a;if(typeof e.total_cost_usd=="number")return e.total_cost_usd;for(let t of Object.values(e)){let s=Vi(t);if(typeof s=="number")return s}}function nr(a){if(!a||typeof a!="object")return;let e=a;if(e.type==="result"&&typeof e.result=="string"&&e.result.trim())return e.result}function Hn(a){if(!a||typeof a!="object")return;let e=a;if(e.modelUsage&&typeof e.modelUsage=="object"){let s=Object.keys(e.modelUsage);if(s.length>0&&s[0])return s[0]}let t=e.message;if(t&&typeof t.model=="string"&&t.model)return t.model;if(typeof e.model=="string"&&e.model)return e.model;for(let s of Object.values(e)){let n=Hn(s);if(n)return n}}function yd(a){return/^gpt-|codex/i.test(a.trim())}var Yi={id:"claude-code",label:"Claude Code",cliPath(a){return a.claudeCliPath},buildExec(a){let e=["-p","--output-format",a.streaming?"stream-json":"json"];a.streaming&&e.push("--verbose");let t=a.modelSource==="settings"&&yd(a.model);a.model&&!t&&e.push("--model",a.model),a.effort&&e.push("--effort",a.effort);let s=a.agent.permissionMode?.trim();return s&&s!=="default"?e.push("--permission-mode",s):e.push("--permission-mode","bypassPermissions"),Promise.resolve({cliPath:a.settings.claudeCliPath,args:e,stdinPayload:a.prompt})},parseExecOutput(a,e,t){let s=a.trim(),n;if(t){let l=re(s);for(let c=l.length-1;c>=0;c--){let d=l[c]?.trim();if(!d)continue;let u=es(d);if(u&&typeof u=="object"){n=u;break}}n===void 0&&s&&Hs("Claude Code stream-json output contained no parseable JSON event",s)}else(s.startsWith("{")||s.startsWith("["))&&(n=qi("Claude Code JSON output failed to parse",s));let r=zi(n)??"";if(!r&&t){let l=[];for(let c of re(s)){let d=c.trim();if(!d)continue;let u=es(d);if(!(!u||typeof u!="object"))if(u.type==="assistant"&&u.message?.content)for(let h of u.message.content)h.type==="text"&&h.text&&l.push(h.text);else u.type==="result"&&typeof u.result=="string"&&l.push(u.result)}r=l.join(` +`).trim()}r||(r=e.trim()||"(no output)");let i=Hn(n),o=nr(n);if((!i||!o)&&t)for(let l of re(s)){let c=l.trim();if(!c)continue;let d=es(c);if(d!==void 0){if(!i){let u=Hn(d);u&&(i=u)}if(!o){let u=nr(d);u&&(o=u)}if(i&&o)break}}return{outputText:r,finalResult:o,tokensUsed:Gi(n),costUsd:Vi(n),toolsUsed:sr(n),concreteModel:i,rawJson:n}},extractStreamChunk(a){let e=a.trim();if(!e)return null;let t=es(e);if(!t||typeof t!="object"||Array.isArray(t))return null;let s=t,n=s.type;if(n==="assistant"){let r=s.message;if(r?.content&&Array.isArray(r.content)){let i=[];for(let o of r.content)if(o.type==="text"&&typeof o.text=="string")i.push(o.text);else if(o.type==="tool_use"){let l=String(o.name??"tool"),c=o.input,d=c?.command??c?.content??"";i.push(` +\u25B8 ${l}${d?`: ${String(d).slice(0,200)}`:""} +`)}if(i.length>0)return i.join("")}}if(n==="result"){let r=typeof s.result=="string"?s.result:null;if(r)return` +${r}`}return null},setupPermissions(a,e,t,s){let n=jn(a,e,{mcpAllowServers:s?.mcpAllowServers});return n?Promise.resolve({restore:()=>Zt(n)}):Promise.resolve(null)}};var se=require("fs"),ir=require("crypto"),qs=require("os"),Xe=require("path");var vd=/^[A-Z][A-Za-z]*$/;function wd(a){let e=a.trim();if(!e)return{error:"empty Bash() matches every command \u2014 use the read-only sandbox instead"};let t=e.split(/\s+/).filter(Boolean),s=[];for(let n=0;nJSON.stringify(s)).join(", ")}], decision="${e}", justification="agent-fleet")`}function kd(a){let e=[],t=[],s=(r,i)=>{let o=r.trim();if(!o||o.startsWith("mcp__"))return;let l=o.match(/^Bash\((.*)\)$/s);if(!l){t.push({rule:o,reason:vd.test(o)?"tool-name rules are governed by the sandbox, not execpolicy":"not a Bash(...) command pattern"});return}let c=wd(l[1]);if("error"in c){t.push({rule:o,reason:c.error});return}e.push(bd(c.tokens,i))};for(let r of a.permissionRules.deny)s(r,"forbidden");for(let r of a.permissionRules.allow)s(r,"allow");return e.length===0?{rulesText:null,dropped:t,emitted:0}:{rulesText:`${`# Generated by Agent Fleet for agent "${a.name}". Do not edit \u2014 regenerated each run. # Source: this agent's permissionRules (allow/deny), translated to Codex execpolicy. -`}${t.join(` +`}${e.join(` `)} -`,dropped:e,emitted:t.length}}function Hl(){let r=process.env.CODEX_HOME?.trim();return r||(0,Xe.join)((0,ys.homedir)(),".codex")}var Qr=(0,Xe.join)((0,ys.tmpdir)(),"agent-fleet-codex");function ql(r){let t=r.name.replace(/[^A-Za-z0-9_-]+/g,"-").replace(/^-+|-+$/g,"")||"agent",e=(0,fa.createHash)("sha1").update(r.filePath).digest("hex").slice(0,8);return(0,Xe.join)(Qr,`${t}-${e}`)}function zl(r,t){let e=`${r}.${process.pid}.${Date.now()}.tmp`;(0,le.writeFileSync)(e,t,"utf-8"),(0,le.renameSync)(e,r)}function Gl(r,t){try{let e=(0,le.lstatSync)(t);if(e.isSymbolicLink()){try{if((0,le.readlinkSync)(t)===r)return}catch{}(0,le.unlinkSync)(t)}else e.isDirectory()?(0,le.rmSync)(t,{recursive:!0,force:!0}):(0,le.unlinkSync)(t)}catch{}(0,le.symlinkSync)(r,t)}function Vl(r,t,e=Hl()){if(!(0,le.existsSync)(e))return null;let s=ql(r);(0,le.mkdirSync)(s,{recursive:!0});for(let o of(0,le.readdirSync)(e))o!=="rules"&&Gl((0,Xe.join)(e,o),(0,Xe.join)(s,o));let n=(0,Xe.join)(s,"rules");(0,le.mkdirSync)(n,{recursive:!0});let a=(0,Xe.join)(e,"rules");if((0,le.existsSync)(a)){for(let o of(0,le.readdirSync)(a))if(o.endsWith(".rules"))try{(0,le.copyFileSync)((0,Xe.join)(a,o),(0,Xe.join)(n,`user-${o}`))}catch{}}let i=(0,Xe.join)(n,"agent-fleet.rules");return zl(i,t),{codexHome:s,rulesPath:i}}function Zr(){try{(0,le.rmSync)(Qr,{recursive:!0,force:!0})}catch{}}var pa=new Map,ma=new Map;function ei(){pa.clear(),ma.clear()}function ti(r,t,e){return new Promise(s=>{let n=!1,a=i=>{n||(n=!0,s(i))};try{let i=dt(r,["execpolicy","check","--rules",t,...e]),o=window.setTimeout(()=>{try{i.kill()}catch{}a(null)},8e3);i.on("error",()=>{window.clearTimeout(o),a(null)}),i.on("close",c=>{window.clearTimeout(o),a(c)})}catch{a(null)}})}function Yl(r){let t=pa.get(r);if(t)return t;let e=(async()=>{let s=null;try{s=(0,le.mkdtempSync)((0,Xe.join)((0,ys.tmpdir)(),"af-codex-probe-"));let n=(0,Xe.join)(s,"probe.rules");return(0,le.writeFileSync)(n,`prefix_rule(pattern=["ls"], decision="allow") -`,"utf-8"),await ti(r,n,["ls"])===0}catch{return!1}finally{if(s)try{(0,le.rmSync)(s,{recursive:!0,force:!0})}catch{}}})();return pa.set(r,e),e}async function Kl(r,t,e){let s=`${r}:${(0,fa.createHash)("sha1").update(e).digest("hex")}`,n=ma.get(s);if(n!==void 0)return n;let i=await ti(r,t,["true"])===0;return ma.set(s,i),i}var Xr=new Set;function gs(r,t){Xr.has(r)||(Xr.add(r),console.warn(`Agent Fleet: ${t}`))}async function si(r,t){if(!(r.permissionRules.allow.length>0||r.permissionRules.deny.length>0))return null;let{rulesText:s,dropped:n}=Wl(r);if(n.length>0&&gs(`dropped:${r.name}:${n.map(l=>l.rule).join("|")}`,`agent "${r.name}": ${n.length} permission rule(s) can't be enforced by Codex and were ignored \u2014 ${n.map(l=>`"${l.rule}" (${l.reason})`).join("; ")}. File/network access is governed by the sandbox (Permission Mode).`),!s)return null;let a=t.codexCliPath;if(!await Yl(a))return gs(`unsupported:${a}`,`this Codex build doesn't support execpolicy rules; agent "${r.name}" runs with sandbox-only enforcement. Update Codex to enforce command allow/deny rules.`),null;let o;try{o=Vl(r,s)}catch(l){return gs(`overlay-fail:${r.name}`,`couldn't build the Codex permission overlay for agent "${r.name}" (${l instanceof Error?l.message:String(l)}); falling back to sandbox-only (symlinks may be unavailable on this platform).`),null}return o?await Kl(a,o.rulesPath,s)?{restore:()=>{},env:{CODEX_HOME:o.codexHome}}:(gs(`invalid-rules:${r.name}`,`generated Codex rules for agent "${r.name}" failed validation; falling back to sandbox-only.`),null):(gs("no-codex-home",`Codex home (~/.codex) not found; agent "${r.name}" runs with sandbox-only enforcement. Run \`codex login\` first.`),null)}function Jl(r){let t=r.trim();return/^(opus|sonnet|haiku|opusplan)$/i.test(t)||/claude|anthropic/i.test(t)}function Xl(r){let t=r.trim().toLowerCase();return t?t==="max"?"xhigh":["low","medium","high","xhigh"].includes(t)?t:"":""}function Ql(r){switch((r??"").trim()){case"plan":case"read-only":return["--sandbox","read-only"];case"acceptEdits":case"default":case"workspace-write":return["--sandbox","workspace-write"];case"danger-full-access":return["--sandbox","danger-full-access"];default:return["--dangerously-bypass-approvals-and-sandbox"]}}function Zl(r){let t=["exec","--json","--skip-git-repo-check"],e=r.modelSource==="settings"&&Jl(r.model);r.model&&!e&&t.push("-m",r.model);let s=Xl(r.effort);return s&&t.push("-c",`model_reasoning_effort="${s}"`),t.push(...Ql(r.agent.permissionMode)),r.resumeSessionId&&t.push("resume",r.resumeSessionId),t.push("-"),{args:t,stdinPayload:r.prompt}}function ni(r){let t=r.trim();if(!t)return null;try{let e=JSON.parse(t);return e&&typeof e=="object"&&!Array.isArray(e)?e:null}catch{return null}}function an(r){let t=r.item;return t&&typeof t=="object"?t:null}function ga(r){let t=typeof r.type=="string"?r.type:"";if(t==="command_execution")return{tool:"shell",command:typeof r.command=="string"?r.command:void 0};if(t==="mcp_tool_call"){let e=typeof r.server=="string"?r.server:"mcp",s=typeof r.tool=="string"?r.tool:"tool";return{tool:`mcp__${e}__${s}`}}return t==="web_search"?{tool:"web_search",command:typeof r.query=="string"?r.query:void 0}:t==="file_change"?{tool:"file_change",command:(Array.isArray(r.changes)?r.changes:[]).map(n=>`${typeof n.kind=="string"?n.kind:"update"} ${typeof n.path=="string"?n.path:""}`.trim()).filter(Boolean).join(", ")||void 0}:null}function ai(r){let t=r.usage;if(!t||typeof t!="object")return 0;let e=typeof t.input_tokens=="number"?t.input_tokens:0,s=typeof t.output_tokens=="number"?t.output_tokens:0;return e+s}function ec(r){let t=r.usage;return!t||typeof t!="object"?0:typeof t.input_tokens=="number"?t.input_tokens:0}function ya(){return{emittedTextLengths:new Map}}function ri(r,t){let e=typeof r.type=="string"?r.type:"",s=[];if(e==="thread.started"&&typeof r.thread_id=="string"&&r.thread_id)return s.push({kind:"session",sessionId:r.thread_id}),s;if(e==="turn.completed")return s.push({kind:"usage",contextTokens:ec(r),totalTokens:ai(r)}),s;if(e==="turn.failed"){let n=r.error,a=n&&typeof n.message=="string"?n.message:"turn failed";return s.push({kind:"turn-failed",message:a}),s}if(e==="error"){let n=typeof r.message=="string"?r.message:"stream error";return s.push({kind:"error",message:n}),s}if(e==="item.started"||e==="item.updated"||e==="item.completed"){let n=an(r);if(!n)return s;let a=typeof n.type=="string"?n.type:"";if(a==="agent_message"){let i=typeof n.text=="string"?n.text:"",o=typeof n.id=="string"?n.id:"__single__",c=t.emittedTextLengths.get(o)??0;return i.length>c&&(s.push({kind:"text",text:i.slice(c)}),t.emittedTextLengths.set(o,i.length)),s}if(a==="error"){let i=typeof n.message=="string"?n.message:"item error";return s.push({kind:"error",message:i}),s}if(e==="item.started"){let i=ga(n);i&&s.push({kind:"tool",toolName:i.tool,command:i.command})}return s}return s}var vs={id:"codex",label:"Codex",cliPath(r){return r.codexCliPath},buildExec(r){let{args:t,stdinPayload:e}=Zl(r);return Promise.resolve({cliPath:r.settings.codexCliPath,args:t,stdinPayload:e})},parseExecOutput(r,t,e){let s=[],n=[],a=[],i=0,o,c;for(let d of ye(r)){let u=ni(d);if(!u)continue;let p=typeof u.type=="string"?u.type:"";if(p==="thread.started"&&typeof u.thread_id=="string")o=u.thread_id;else if(p==="turn.completed")i+=ai(u),c=u;else if(p==="turn.failed"){let m=u.error;a.push(m&&typeof m.message=="string"?m.message:"turn failed")}else if(p==="error")a.push(typeof u.message=="string"?u.message:"stream error");else if(p==="item.completed"){let m=an(u);if(!m)continue;let f=typeof m.type=="string"?m.type:"";if(f==="agent_message"&&typeof m.text=="string"&&m.text.trim())s.push(m.text);else if(f==="error"&&typeof m.message=="string")a.push(m.message);else{let g=ga(m);g&&n.push(g)}}}let l=s.join(` +`,dropped:t,emitted:e.length}}function xd(){let a=process.env.CODEX_HOME?.trim();return a||(0,Xe.join)((0,qs.homedir)(),".codex")}var Ji=(0,Xe.join)((0,qs.tmpdir)(),"agent-fleet-codex");function Sd(a){let e=a.name.replace(/[^A-Za-z0-9_-]+/g,"-").replace(/^-+|-+$/g,"")||"agent",t=(0,ir.createHash)("sha1").update(a.filePath).digest("hex").slice(0,8);return(0,Xe.join)(Ji,`${e}-${t}`)}function Cd(a,e){let t=`${a}.${process.pid}.${Date.now()}.tmp`;(0,se.writeFileSync)(t,e,"utf-8"),(0,se.renameSync)(t,a)}function Td(a,e){try{let t=(0,se.lstatSync)(e);if(t.isSymbolicLink()){try{if((0,se.readlinkSync)(e)===a)return}catch{}(0,se.unlinkSync)(e)}else t.isDirectory()?(0,se.rmSync)(e,{recursive:!0,force:!0}):(0,se.unlinkSync)(e)}catch{}(0,se.symlinkSync)(a,e)}function _d(a,e,t=xd()){if(!(0,se.existsSync)(t))return null;let s=Sd(a);(0,se.mkdirSync)(s,{recursive:!0});for(let o of(0,se.readdirSync)(t))o!=="rules"&&Td((0,Xe.join)(t,o),(0,Xe.join)(s,o));let n=(0,Xe.join)(s,"rules");(0,se.mkdirSync)(n,{recursive:!0});let r=(0,Xe.join)(t,"rules");if((0,se.existsSync)(r)){for(let o of(0,se.readdirSync)(r))if(o.endsWith(".rules"))try{(0,se.copyFileSync)((0,Xe.join)(r,o),(0,Xe.join)(n,`user-${o}`))}catch(l){console.warn(`Agent Fleet: couldn't copy the Codex rules file ${(0,Xe.join)(r,o)} into agent "${a.name}"'s permission overlay (${l instanceof Error?l.message:String(l)}); its rules will NOT apply to this agent's runs.`)}}let i=(0,Xe.join)(n,"agent-fleet.rules");return Cd(i,e),{codexHome:s,rulesPath:i}}function Xi(){try{(0,se.rmSync)(Ji,{recursive:!0,force:!0})}catch{}}var ar=new Map,rr=new Map;function Qi(){ar.clear(),rr.clear()}function Zi(a,e,t){return new Promise(s=>{let n=!1,r=i=>{n||(n=!0,s(i))};try{let i=wt(a,["execpolicy","check","--rules",e,...t]),o=window.setTimeout(()=>{try{i.kill()}catch{}r(null)},8e3);i.on("error",()=>{window.clearTimeout(o),r(null)}),i.on("close",l=>{window.clearTimeout(o),r(l)})}catch{r(null)}})}function Ad(a){let e=ar.get(a);if(e)return e;let t=(async()=>{let s=null;try{s=(0,se.mkdtempSync)((0,Xe.join)((0,qs.tmpdir)(),"af-codex-probe-"));let n=(0,Xe.join)(s,"probe.rules");return(0,se.writeFileSync)(n,`prefix_rule(pattern=["ls"], decision="allow") +`,"utf-8"),await Zi(a,n,["ls"])===0}catch{return!1}finally{if(s)try{(0,se.rmSync)(s,{recursive:!0,force:!0})}catch{}}})();return ar.set(a,t),t}async function Pd(a,e,t){let s=`${a}:${(0,ir.createHash)("sha1").update(t).digest("hex")}`,n=rr.get(s);if(n!==void 0)return n;let i=await Zi(a,e,["true"])===0;return rr.set(s,i),i}var Ki=new Set;function Ws(a,e){Ki.has(a)||(Ki.add(a),console.warn(`Agent Fleet: ${e}`))}async function eo(a,e){if(!(a.permissionRules.allow.length>0||a.permissionRules.deny.length>0))return null;let{rulesText:s,dropped:n}=kd(a);if(n.length>0&&Ws(`dropped:${a.name}:${n.map(c=>c.rule).join("|")}`,`agent "${a.name}": ${n.length} permission rule(s) can't be enforced by Codex and were ignored \u2014 ${n.map(c=>`"${c.rule}" (${c.reason})`).join("; ")}. File/network access is governed by the sandbox (Permission Mode).`),!s)return null;let r=e.codexCliPath;if(!await Ad(r))return Ws(`unsupported:${r}`,`this Codex build doesn't support execpolicy rules; agent "${a.name}" runs with sandbox-only enforcement. Update Codex to enforce command allow/deny rules.`),null;let o;try{o=_d(a,s)}catch(c){return Ws(`overlay-fail:${a.name}`,`couldn't build the Codex permission overlay for agent "${a.name}" (${c instanceof Error?c.message:String(c)}); falling back to sandbox-only (symlinks may be unavailable on this platform).`),null}return o?await Pd(r,o.rulesPath,s)?{restore:()=>{},env:{CODEX_HOME:o.codexHome}}:(Ws(`invalid-rules:${a.name}`,`generated Codex rules for agent "${a.name}" failed validation; falling back to sandbox-only.`),null):(Ws("no-codex-home",`Codex home (~/.codex) not found; agent "${a.name}" runs with sandbox-only enforcement. Run \`codex login\` first.`),null)}function Ed(a){let e=a.trim();return/^(opus|sonnet|haiku|opusplan)$/i.test(e)||/claude|anthropic/i.test(e)}function Dd(a){let e=a.trim().toLowerCase();return e?e==="max"?"xhigh":["low","medium","high","xhigh"].includes(e)?e:"":""}function Rd(a){switch((a??"").trim()){case"plan":case"read-only":return["--sandbox","read-only"];case"acceptEdits":case"default":case"workspace-write":return["--sandbox","workspace-write"];case"danger-full-access":return["--sandbox","danger-full-access"];default:return["--dangerously-bypass-approvals-and-sandbox"]}}function Id(a){let e=["exec","--json","--skip-git-repo-check"],t=a.modelSource==="settings"&&Ed(a.model);a.model&&!t&&e.push("-m",a.model);let s=Dd(a.effort);return s&&e.push("-c",`model_reasoning_effort="${s}"`),e.push(...Rd(a.agent.permissionMode)),a.resumeSessionId&&e.push("resume",a.resumeSessionId),e.push("-"),{args:e,stdinPayload:a.prompt}}function to(a){let e=a.trim();if(!e)return null;let t=es(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:null}function Wn(a){let e=a.item;return e&&typeof e=="object"?e:null}function or(a){let e=typeof a.type=="string"?a.type:"";if(e==="command_execution")return{tool:"shell",command:typeof a.command=="string"?a.command:void 0};if(e==="mcp_tool_call"){let t=typeof a.server=="string"?a.server:"mcp",s=typeof a.tool=="string"?a.tool:"tool";return{tool:`mcp__${t}__${s}`}}return e==="web_search"?{tool:"web_search",command:typeof a.query=="string"?a.query:void 0}:e==="file_change"?{tool:"file_change",command:(Array.isArray(a.changes)?a.changes:[]).map(n=>`${typeof n.kind=="string"?n.kind:"update"} ${typeof n.path=="string"?n.path:""}`.trim()).filter(Boolean).join(", ")||void 0}:null}function so(a){let e=a.usage;if(!e||typeof e!="object")return 0;let t=typeof e.input_tokens=="number"?e.input_tokens:0,s=typeof e.output_tokens=="number"?e.output_tokens:0;return t+s}function Md(a){let e=a.usage;return!e||typeof e!="object"?0:typeof e.input_tokens=="number"?e.input_tokens:0}function lr(){return{emittedTextLengths:new Map}}function no(a,e){let t=typeof a.type=="string"?a.type:"",s=[];if(t==="thread.started"&&typeof a.thread_id=="string"&&a.thread_id)return s.push({kind:"session",sessionId:a.thread_id}),s;if(t==="turn.completed")return s.push({kind:"usage",contextTokens:Md(a),totalTokens:so(a)}),s;if(t==="turn.failed"){let n=a.error,r=n&&typeof n.message=="string"?n.message:"turn failed";return s.push({kind:"turn-failed",message:r}),s}if(t==="error"){let n=typeof a.message=="string"?a.message:"stream error";return s.push({kind:"error",message:n}),s}if(t==="item.started"||t==="item.updated"||t==="item.completed"){let n=Wn(a);if(!n)return s;let r=typeof n.type=="string"?n.type:"";if(r==="agent_message"){let i=typeof n.text=="string"?n.text:"",o=typeof n.id=="string"?n.id:"__single__",l=e.emittedTextLengths.get(o)??0;return i.length>l&&(s.push({kind:"text",text:i.slice(l)}),e.emittedTextLengths.set(o,i.length)),s}if(r==="error"){let i=typeof n.message=="string"?n.message:"item error";return s.push({kind:"error",message:i}),s}if(t==="item.started"){let i=or(n);i&&s.push({kind:"tool",toolName:i.tool,command:i.command})}return s}return s}var zs={id:"codex",label:"Codex",cliPath(a){return a.codexCliPath},buildExec(a){let{args:e,stdinPayload:t}=Id(a);return Promise.resolve({cliPath:a.settings.codexCliPath,args:e,stdinPayload:t})},parseExecOutput(a,e,t){let s=[],n=[],r=[],i=0,o,l,c=!1;for(let h of re(a)){let p=to(h);if(!p)continue;c=!0;let m=typeof p.type=="string"?p.type:"";if(m==="thread.started"&&typeof p.thread_id=="string")o=p.thread_id;else if(m==="turn.completed")i+=so(p),l=p;else if(m==="turn.failed"){let f=p.error;r.push(f&&typeof f.message=="string"?f.message:"turn failed")}else if(m==="error")r.push(typeof p.message=="string"?p.message:"stream error");else if(m==="item.completed"){let f=Wn(p);if(!f)continue;let g=typeof f.type=="string"?f.type:"";if(g==="agent_message"&&typeof f.text=="string"&&f.text.trim())s.push(f.text);else if(g==="error"&&typeof f.message=="string")r.push(f.message);else{let w=or(f);w&&n.push(w)}}}!c&&a.trim()&&Hs("Codex exec output contained no parseable JSONL event",a.trim());let d=s.join(` -`).trim();l||(l=a.join(` -`).trim()),l||(l=t.trim()||"(no output)");let h=s[s.length-1];return{outputText:l,finalResult:h?.trim()?h:void 0,tokensUsed:i>0?i:void 0,costUsd:void 0,toolsUsed:n,concreteModel:void 0,rawJson:c,sessionId:o}},extractStreamChunk(r){let t=ni(r);if(!t)return null;let e=typeof t.type=="string"?t.type:"";if(e==="item.completed"){let s=an(t);return s&&s.type==="agent_message"&&typeof s.text=="string"&&s.text.trim()?s.text:null}if(e==="item.started"){let s=an(t);if(!s)return null;let n=ga(s);if(n){let a=n.command?`: ${n.command.slice(0,200)}`:"";return` -\u25B8 ${n.tool}${a} -`}return null}if(e==="turn.failed"){let s=t.error;return` +`).trim();d||(d=r.join(` +`).trim()),d||(d=e.trim()||"(no output)");let u=s[s.length-1];return{outputText:d,finalResult:u?.trim()?u:void 0,tokensUsed:i>0?i:void 0,costUsd:void 0,toolsUsed:n,concreteModel:void 0,rawJson:l,sessionId:o}},extractStreamChunk(a){let e=to(a);if(!e)return null;let t=typeof e.type=="string"?e.type:"";if(t==="item.completed"){let s=Wn(e);return s&&s.type==="agent_message"&&typeof s.text=="string"&&s.text.trim()?s.text:null}if(t==="item.started"){let s=Wn(e);if(!s)return null;let n=or(s);if(n){let r=n.command?`: ${n.command.slice(0,200)}`:"";return` +\u25B8 ${n.tool}${r} +`}return null}if(t==="turn.failed"){let s=e.error;return` \u2716 ${s&&typeof s.message=="string"?s.message:"turn failed"} -`}return null},setupPermissions(r,t,e,s){return si(t,e)}};function kt(r){let t=(r??"").trim().toLowerCase();return t==="codex"||t==="openai-codex"?"codex":"claude-code"}function ii(r){return kt(r)==="codex"?vs:Jr}var oi=new Set(["","default","subscription"]);function It(r,t,e){let s=va(r?.model);if(s)return{value:wa(s),source:"task"};let n=va(t.model);if(n)return{value:wa(n),source:"agent"};let a=va(e.defaultModel);return a?{value:wa(a),source:"settings"}:{value:"",source:"cli-default"}}function ws(r){return!oi.has(r.trim())}function va(r){if(!r)return"";let t=r.trim();return t||""}function wa(r){return oi.has(r)?"":r}function rn(r,t){let e=r.wikiReferences;if(!e||e.length===0)return null;let s=[];for(let a of e){let i=t.getAgentByName(a.agent);if(!i||!i.wikiKeeper)continue;let o=i.wikiKeeper,c=o.scopeRoot?`${o.scopeRoot.replace(/\/+$/,"")}/`:"";s.push({agentName:i.name,scopeRoot:o.scopeRoot||"(whole vault)",topicsPath:`${c}${o.topicsRoot}`,inboxPath:`${c}${o.inboxPath}`,indexPath:`${c}${o.indexPath}`})}if(s.length===0)return null;let n=["## Wiki Access"];n.push("You have read access to the following wikis maintained by other agents. Use the `wiki-query` skill in consumer mode when the user asks something a wiki may already cover."),n.push("");for(let a of s)n.push(`### Wiki: \`${a.agentName}\``),n.push(`- scope root: \`${a.scopeRoot}\``),n.push(`- topics: \`${a.topicsPath}/\``),n.push(`- index: \`${a.indexPath}\``),n.push(`- inbox: \`${a.inboxPath}/\``),n.push("");return n.push("### Rules"),n.push("- **Cite every factual claim** from a wiki using `[[/]]`. If a claim has no page, say the wiki doesn't cover it \u2014 do not fabricate."),n.push("- **When the user shares something durable** that isn't yet in a wiki (a decision, a new entity mention, a competitor change, a meeting outcome), write a short markdown file to the relevant wiki's inbox at `/YYYY-MM-DD-.md` with a one-line note + the source. The wiki keeper files it canonically on its next ingest."),n.push("- **Do NOT write to `/` directly.** The wiki keeper is the canonical curator of topic pages. Use the inbox as your deposit point."),n.push("- **When the question spans multiple wikis**, be explicit about which scope each cited page belongs to."),n.join(` -`)}var ht=require("fs"),ln=require("path");var on=require("fs"),tc=require("path");var ba="remember",vu=`mcp__${ba}`,li=String.raw` +`}return null},setupPermissions(a,e,t,s){return eo(e,t)}};function Bt(a){let e=(a??"").trim().toLowerCase();return e==="codex"||e==="openai-codex"?"codex":"claude-code"}function ao(a){return Bt(a)==="codex"?zs:Yi}var ro=new Set(["","default","subscription"]);function ts(a,e,t){let s=cr(a?.model);if(s)return{value:dr(s),source:"task"};let n=cr(e.model);if(n)return{value:dr(n),source:"agent"};let r=cr(t.defaultModel);return r?{value:dr(r),source:"settings"}:{value:"",source:"cli-default"}}function Gs(a){return!ro.has(a.trim())}function cr(a){if(!a)return"";let e=a.trim();return e||""}function dr(a){return ro.has(a)?"":a}function io(a,e){let t=a.wikiReferences;if(!t||t.length===0)return null;let s=[];for(let r of t){let i=e.getAgentByName(r.agent);if(!i||!i.wikiKeeper)continue;let o=i.wikiKeeper,l=o.scopeRoot?`${o.scopeRoot.replace(/\/+$/,"")}/`:"";s.push({agentName:i.name,scopeRoot:o.scopeRoot||"(whole vault)",topicsPath:`${l}${o.topicsRoot}`,inboxPath:`${l}${o.inboxPath}`,indexPath:`${l}${o.indexPath}`})}if(s.length===0)return null;let n=["## Wiki Access"];n.push("You have read access to the following wikis maintained by other agents. Use the `wiki-query` skill in consumer mode when the user asks something a wiki may already cover."),n.push("");for(let r of s)n.push(`### Wiki: \`${r.agentName}\``),n.push(`- scope root: \`${r.scopeRoot}\``),n.push(`- topics: \`${r.topicsPath}/\``),n.push(`- index: \`${r.indexPath}\``),n.push(`- inbox: \`${r.inboxPath}/\``),n.push("");return n.push("### Rules"),n.push("- **Cite every factual claim** from a wiki using `[[/]]`. If a claim has no page, say the wiki doesn't cover it \u2014 do not fabricate."),n.push("- **When the user shares something durable** that isn't yet in a wiki (a decision, a new entity mention, a competitor change, a meeting outcome), write a short markdown file to the relevant wiki's inbox at `/YYYY-MM-DD-.md` with a one-line note + the source. The wiki keeper files it canonically on its next ingest."),n.push("- **Do NOT write to `/` directly.** The wiki keeper is the canonical curator of topic pages. Use the inbox as your deposit point."),n.push("- **When the question spans multiple wikis**, be explicit about which scope each cited page belongs to."),n.join(` +`)}async function qn(a,e,t){let s=[e.body.trim()];for(let r of e.skills){let i=a.getSkillByName(r);if(i){let o=[i.body.trim()];i.toolsBody.trim()&&o.push(`### Tools +${i.toolsBody.trim()}`),i.referencesBody.trim()&&o.push(`### References +${i.referencesBody.trim()}`),i.examplesBody.trim()&&o.push(`### Examples +${i.examplesBody.trim()}`),s.push(`## Skill: ${i.name} +${o.join(` + +`)}`)}}if(e.skillsBody.trim()&&s.push(`## Agent Skills +${e.skillsBody.trim()}`),e.contextBody.trim()&&s.push(`## Agent Context +${e.contextBody.trim()}`),t.memoryActive){let r=await a.readWorkingMemory(e.name),i=ki(e,r);i&&s.push(i)}t.channelContext&&t.channelContext.trim()&&s.push(`## Channel Context +${t.channelContext.trim()}`);let n=io(e,a);return n&&s.push(n),s}var uo=require("crypto"),kt=require("fs"),Gn=require("path");var Fd=require("crypto"),zn=require("fs"),Ld=require("path");var ur="remember",tf=`mcp__${ur}`,oo=String.raw` const fs = require("fs"); const path = require("path"); const PENDING_DIR = process.env.AF_PENDING_DIR; @@ -11882,57 +11892,43 @@ function handle(req) { send({ jsonrpc: "2.0", id, error: { code: -32601, message: "method not found: " + method } }); } } -`;function ci(r){let t=[];for(let e of r){let s=e.trim();if(s)try{let n=JSON.parse(s),a=typeof n.text=="string"?n.text.trim():"";if(!a)continue;let i=n.section==="Preferences"||n.section==="Procedures"||n.section==="Observations"?n.section:void 0;t.push({text:a,pinned:n.pinned===!0,section:i,source:typeof n.source=="string"?n.source:"mcp"})}catch{}}return t}var sc=0;function nc(r,t){return{def:{name:ba,type:"stdio",enabled:!0,command:"node",env:{AF_PENDING_DIR:r,AF_SOURCE:t},status:"connected",scope:"user",tools:[],toolDetails:[]},inlineScript:li}}function cn(r){let t=r.agentGrants.map(n=>n.trim().toLowerCase()).filter(Boolean),e=t.length>0?new Set(t):null,s=[];for(let n of r.registry){if(!n.enabled||e&&!e.has(n.name.trim().toLowerCase())||n.type==="unknown")continue;let a={};if(n.type==="stdio"){if(n.envSecretKeys&&n.envSecretKeys.length>0){let i={};for(let o of n.envSecretKeys){let c=process.env[o];c&&(i[o]=c)}Object.keys(i).length>0&&(a.env=i)}}else if(n.auth!=="none"){let i=r.getBearerToken(n.name);i&&(a.bearerToken=i)}s.push({def:n,secrets:a})}return r.remember&&s.push(nc(r.remember.pendingDir,r.remember.source)),s}function ac(r){return`AF_MCP_${r.toUpperCase().replace(/[^A-Z0-9]+/g,"_").replace(/^_+|_+$/g,"")||"SERVER"}_TOKEN`}function di(r){return/^[A-Za-z0-9_-]+$/.test(r)?r:`"${r.replace(/"/g,'\\"')}"`}function rc(r,t){let{def:e,secrets:s}=r;if(e.type==="unknown")throw new Error(`server ${e.name} has unknown transport`);let n=e.type;if(n==="stdio"){let a=e.command??"node",i=t?[t]:e.args??[],o={...e.env??{},...s?.env??{}};return{name:e.name,type:n,command:a,args:i,env:o}}if(!e.url)throw new Error(`server ${e.name} (${n}) has no url`);return{name:e.name,type:n,url:e.url,headers:e.headers,bearerToken:s?.bearerToken,oauthResource:e.oauth?.resource,oauthClientId:e.oauth?.clientId}}function ic(r){if(r.type==="stdio"){let s={command:r.command,args:r.args??[]};return r.env&&Object.keys(r.env).length>0&&(s.env=r.env),s}let t={...r.headers??{}};r.bearerToken&&(t.Authorization=`Bearer ${r.bearerToken}`);let e={type:r.type,url:r.url};return Object.keys(t).length>0&&(e.headers=t),e}function oc(r){let t=di(r.name),e=[],s={};if(r.type==="stdio"){e.push("-c",`mcp_servers.${t}.command=${JSON.stringify(r.command??"node")}`),r.args&&r.args.length>0&&e.push("-c",`mcp_servers.${t}.args=${JSON.stringify(r.args)}`);for(let[n,a]of Object.entries(r.env??{}))e.push("-c",`mcp_servers.${t}.env.${di(n)}=${JSON.stringify(a)}`)}else{if(e.push("-c",`mcp_servers.${t}.url=${JSON.stringify(r.url)}`),r.bearerToken){let n=ac(r.name);s[n]=r.bearerToken,e.push("-c",`mcp_servers.${t}.bearer_token_env_var=${JSON.stringify(n)}`)}r.oauthResource&&e.push("-c",`mcp_servers.${t}.oauth_resource=${JSON.stringify(r.oauthResource)}`),r.oauthClientId&&e.push("-c",`mcp_servers.${t}.oauth.client_id=${JSON.stringify(r.oauthClientId)}`)}return e.push("-c",`mcp_servers.${t}.enabled=true`),{args:e,env:s}}function dn(r,t,e){if(e.length===0)return null;let s=kt(t)==="codex",n=[],a=(0,ln.join)(r,".claude");try{(0,ht.existsSync)(a)||(0,ht.mkdirSync)(a,{recursive:!0});let i=`${process.pid}-${Date.now()}-${sc++}`,o=[],c=0;for(let d of e){try{let u=null;d.inlineScript&&(u=(0,ln.join)(a,`af-mcp-${lc(d.def.name)}.${i}-${c}.cjs`),(0,ht.writeFileSync)(u,d.inlineScript,"utf-8"),n.push(u)),o.push(rc(d,u))}catch(u){console.warn(`Agent Fleet: skipping MCP server "${d.def.name}" in projection:`,u)}c++}if(o.length===0)return ka(n),null;if(s){let d=[],u={};for(let p of o){let m=oc(p);d.push(...m.args),Object.assign(u,m.env)}return{args:d,env:u,tempFiles:n}}let l={};for(let d of o)l[d.name]=ic(d);let h=(0,ln.join)(a,`af-mcp.${i}.json`);return(0,ht.writeFileSync)(h,JSON.stringify({mcpServers:l},null,2),"utf-8"),n.push(h),{args:["--mcp-config",h],env:{},tempFiles:n}}catch(i){return console.warn("Agent Fleet: couldn't install MCP projection; run proceeds without fleet MCP.",i),ka(n),null}}function xt(r){r&&ka(r.tempFiles)}function ka(r){for(let t of r)try{(0,ht.existsSync)(t)&&(0,ht.unlinkSync)(t)}catch{}}function lc(r){return r.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"server"}var hn=class{constructor(t,e,s){this.settings=t;this.repository=e;this.mcpAuth=s}runningProcesses=new Map;abortAgent(t){let e=this.runningProcesses.get(t);return e?(e.kill(),this.runningProcesses.delete(t),!0):!1}async buildPrompt(t,e,s,n=t.memory){let a=[t.body.trim()];for(let o of t.skills){let c=this.repository.getSkillByName(o);if(c){let l=[c.body.trim()];c.toolsBody.trim()&&l.push(`### Tools -${c.toolsBody.trim()}`),c.referencesBody.trim()&&l.push(`### References -${c.referencesBody.trim()}`),c.examplesBody.trim()&&l.push(`### Examples -${c.examplesBody.trim()}`),a.push(`## Skill: ${c.name} -${l.join(` +`;function lo(a){let e=[];for(let t of a){let s=t.trim();if(s)try{let n=JSON.parse(s),r=typeof n.text=="string"?n.text.trim():"";if(!r)continue;let i=n.section==="Preferences"||n.section==="Procedures"||n.section==="Observations"?n.section:void 0;e.push({text:r,pinned:n.pinned===!0,section:i,source:typeof n.source=="string"?n.source:"mcp"})}catch{}}return e}function Od(a,e){return{def:{name:ur,type:"stdio",enabled:!0,command:"node",env:{AF_PENDING_DIR:a,AF_SOURCE:e},status:"connected",scope:"user",tools:[],toolDetails:[]},inlineScript:oo}}function Vn(a){let e=a.agentGrants.map(n=>n.trim().toLowerCase()).filter(Boolean),t=e.length>0?new Set(e):null,s=[];for(let n of a.registry){if(!n.enabled||t&&!t.has(n.name.trim().toLowerCase())||n.type==="unknown")continue;let r={};if(n.type==="stdio"){if(n.envSecretKeys&&n.envSecretKeys.length>0){let i={};for(let o of n.envSecretKeys){let l=process.env[o];l&&(i[o]=l)}Object.keys(i).length>0&&(r.env=i)}}else if(n.auth!=="none"){let i=a.getBearerToken(n.name);i&&(r.bearerToken=i)}s.push({def:n,secrets:r})}return a.remember&&s.push(Od(a.remember.pendingDir,a.remember.source)),s}function Nd(a){return`AF_MCP_${a.toUpperCase().replace(/[^A-Z0-9]+/g,"_").replace(/^_+|_+$/g,"")||"SERVER"}_TOKEN`}function co(a){return/^[A-Za-z0-9_-]+$/.test(a)?a:`"${a.replace(/"/g,'\\"')}"`}function Bd(a,e){let{def:t,secrets:s}=a;if(t.type==="unknown")throw new Error(`server ${t.name} has unknown transport`);let n=t.type;if(n==="stdio"){let r=t.command??"node",i=e?[e]:t.args??[],o={...t.env??{},...s?.env??{}};return{name:t.name,type:n,command:r,args:i,env:o}}if(!t.url)throw new Error(`server ${t.name} (${n}) has no url`);return{name:t.name,type:n,url:t.url,headers:t.headers,bearerToken:s?.bearerToken,oauthResource:t.oauth?.resource,oauthClientId:t.oauth?.clientId}}function Ud(a){if(a.type==="stdio"){let s={command:a.command,args:a.args??[]};return a.env&&Object.keys(a.env).length>0&&(s.env=a.env),s}let e={...a.headers??{}};a.bearerToken&&(e.Authorization=`Bearer ${a.bearerToken}`);let t={type:a.type,url:a.url};return Object.keys(e).length>0&&(t.headers=e),t}function $d(a){let e=co(a.name),t=[],s={};if(a.type==="stdio"){t.push("-c",`mcp_servers.${e}.command=${JSON.stringify(a.command??"node")}`),a.args&&a.args.length>0&&t.push("-c",`mcp_servers.${e}.args=${JSON.stringify(a.args)}`);for(let[n,r]of Object.entries(a.env??{}))t.push("-c",`mcp_servers.${e}.env.${co(n)}=${JSON.stringify(r)}`)}else{if(t.push("-c",`mcp_servers.${e}.url=${JSON.stringify(a.url)}`),a.bearerToken){let n=Nd(a.name);s[n]=a.bearerToken,t.push("-c",`mcp_servers.${e}.bearer_token_env_var=${JSON.stringify(n)}`)}a.oauthResource&&t.push("-c",`mcp_servers.${e}.oauth_resource=${JSON.stringify(a.oauthResource)}`),a.oauthClientId&&t.push("-c",`mcp_servers.${e}.oauth.client_id=${JSON.stringify(a.oauthClientId)}`)}return t.push("-c",`mcp_servers.${e}.enabled=true`),{args:t,env:s}}function Yn(a,e,t){if(t.length===0)return null;let s=Bt(e)==="codex",n=[],r=(0,Gn.join)(a,".claude");try{(0,kt.existsSync)(r)||(0,kt.mkdirSync)(r,{recursive:!0});let i=(0,uo.randomUUID)(),o=[],l=0;for(let u of t){try{let h=null;u.inlineScript&&(h=(0,Gn.join)(r,`af-mcp-${jd(u.def.name)}.${i}-${l}.cjs`),(0,kt.writeFileSync)(h,u.inlineScript,"utf-8"),n.push(h)),o.push(Bd(u,h))}catch(h){console.warn(`Agent Fleet: skipping MCP server "${u.def.name}" in projection:`,h)}l++}if(o.length===0)return hr(n),null;if(s){let u=[],h={};for(let p of o){let m=$d(p);u.push(...m.args),Object.assign(h,m.env)}return{args:u,env:h,tempFiles:n}}let c={};for(let u of o)c[u.name]=Ud(u);let d=(0,Gn.join)(r,`af-mcp.${i}.json`);return(0,kt.writeFileSync)(d,JSON.stringify({mcpServers:c},null,2),"utf-8"),n.push(d),{args:["--mcp-config",d],env:{},tempFiles:n}}catch(i){return console.warn("Agent Fleet: couldn't install MCP projection; run proceeds without fleet MCP.",i),hr(n),null}}function Ut(a){a&&hr(a.tempFiles)}function hr(a){for(let e of a)try{(0,kt.existsSync)(e)&&(0,kt.unlinkSync)(e)}catch{}}function jd(a){return a.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"server"}var ho=10*1024*1024,po=1024*1024;var Kn=class{constructor(e,t,s){this.settings=e;this.repository=t;this.mcpAuth=s}runningProcesses=new Map;abortAgent(e){let t=this.runningProcesses.get(e);return t?(t.kill(),this.runningProcesses.delete(e),!0):!1}async buildPrompt(e,t,s,n=e.memory){let r=await qn(this.repository,e,{memoryActive:n});return r.push(`## Task +${(s??t.body).trim()}`),r.filter(Boolean).join(` -`)}`)}}if(t.skillsBody.trim()&&a.push(`## Agent Skills -${t.skillsBody.trim()}`),t.contextBody.trim()&&a.push(`## Agent Context -${t.contextBody.trim()}`),n){let o=await this.repository.readWorkingMemory(t.name),c=Gs(t,o);c&&a.push(c)}let i=rn(t,this.repository);return i&&a.push(i),a.push(`## Task -${(s??e.body).trim()}`),a.filter(Boolean).join(` - -`)}async execute(t,e,s,n,a){let i=t.memory&&!a?.suppressMemoryCapture,o=await this.buildPrompt(t,e,s,i),c=(0,hi.randomUUID)(),l=It(e,t,this.settings),h=n!=null,d=ii(t.adapter),u=await d.buildExec({prompt:o,model:ws(l.value)?l.value:"",modelSource:l.source,effort:e.effort||t.effort||"",agent:t,settings:this.settings,streaming:h}),p=t.cwd?.trim()?t.cwd:this.repository.getVaultBasePath()??".",m=i?this.repository.getPendingDirAbsolutePath(t.name):null,f=cn({registry:this.repository.getMcpServers(),agentGrants:t.mcpServers??[],getBearerToken:k=>this.mcpAuth?.getToken(k),remember:m?{pendingDir:m,source:"mcp"}:null}),g=dn(p,t.adapter,f);g&&u.args.push(...g.args);let y=await d.setupPermissions(p,t,this.settings,{mcpAllowServers:f.map(k=>k.def.name)}),v=Date.now();try{return await new Promise((k,w)=>{let S=dt(u.cliPath,u.args,{cwd:p,env:{...process.env,AWS_REGION:this.settings.awsRegion,...g?.env??{},...y?.env??{}}});if(u.stdinPayload!==void 0)try{S.stdin?.write(u.stdinPayload),S.stdin?.end()}catch(C){S.kill(),w(C instanceof Error?C:new Error(String(C)));return}this.runningProcesses.set(t.name,S);let T="",_="",D=!1,O=window.setTimeout(()=>{D=!0,S.kill()},t.timeout*1e3);S.stdout.on("data",C=>{let E=C.toString();if(T+=E,h&&n)for(let P of ye(E)){let N=d.extractStreamChunk(P);N&&n(N)}}),S.stderr.on("data",C=>{_+=C.toString()}),S.on("error",C=>{window.clearTimeout(O),w(C)}),S.on("close",C=>{window.clearTimeout(O),this.runningProcesses.delete(t.name);let E=d.parseExecOutput(T,_,h);k({runId:c,prompt:o,exitCode:C,durationSeconds:Math.max(1,Math.round((Date.now()-v)/1e3)),stdout:T,stderr:_,outputText:E.outputText,rawJson:E.rawJson,tokensUsed:E.tokensUsed,costUsd:E.costUsd,toolsUsed:E.toolsUsed,timedOut:D,resolvedModel:l.value,modelSource:l.source,concreteModel:E.concreteModel,finalResult:E.finalResult})})})}finally{y?.restore(),xt(g)}}};function ui(r){return oe(r).slice(0,60)||"candidate"}function mi(r){let t=Math.max(400,r.tokenBudget*4);return[`You are running a nightly memory **reflection** for the agent "${r.agentName}".`,"Review the raw memory log and your current working memory below, then produce an","updated, consolidated working memory.","","Rules:","- Remove duplicates; resolve contradictions in favor of the NEWEST fact.","- File each fact under exactly one of: Preferences, Procedures, Observations.","- Keep durable user/process preferences, marking them `[pin]` \u2014 never drop or"," summarize pinned Preferences.","- Summarize the Observations section FROM THE RAW LOG (not from prior summaries)",` if needed so the whole memory fits within roughly ${r.tokenBudget} tokens`,` (~${t} characters).`,"- Note any friction that recurred across multiple runs as a skill candidate.","","Reply with EXACTLY these two fenced blocks and nothing else:","","```memory","## Preferences","- [pin] ","## Procedures","- ","## Observations","- ","```","","```candidates",'[{"key":"short-stable-key","pattern":"what recurred","evidence":["runs/..."],"suggestedSkill":"optional-name"}]',"```","","If there is nothing worth remembering, still emit a `memory` block preserving the","existing pinned Preferences. Use an empty array `[]` for candidates when none.","","\u2500\u2500 CURRENT WORKING MEMORY \u2500\u2500",r.workingMemoryBody.trim()||"(empty)","","\u2500\u2500 RAW MEMORY LOG (ground truth) \u2500\u2500",r.recentRaw.trim()||"(empty)",r.recentRunsSummary?` +`)}async execute(e,t,s,n,r){let i=e.memory&&!r?.suppressMemoryCapture,o=await this.buildPrompt(e,t,s,i),l=(0,mo.randomUUID)(),c=ts(t,e,this.settings),d=n!=null,u=ao(e.adapter),h=await u.buildExec({prompt:o,model:Gs(c.value)?c.value:"",modelSource:c.source,effort:t.effort||e.effort||"",agent:e,settings:this.settings,streaming:d}),p=e.cwd?.trim()?e.cwd:this.repository.getVaultBasePath()??".",m=i?this.repository.getPendingDirAbsolutePath(e.name):null,f=Vn({registry:this.repository.getMcpServers(),agentGrants:e.mcpServers??[],getBearerToken:v=>this.mcpAuth?.getToken(v),remember:m?{pendingDir:m,source:"mcp"}:null}),g=Yn(p,e.adapter,f);g&&h.args.push(...g.args);let w=await u.setupPermissions(p,e,this.settings,{mcpAllowServers:f.map(v=>v.def.name)}),y=Date.now();try{return await new Promise((v,k)=>{let b=wt(h.cliPath,h.args,{cwd:p,env:{...process.env,AWS_REGION:this.settings.awsRegion,...g?.env??{},...w?.env??{}}});if(h.stdinPayload!==void 0)try{b.stdin?.write(h.stdinPayload),b.stdin?.end()}catch(_){b.kill(),k(_ instanceof Error?_:new Error(String(_)));return}this.runningProcesses.set(e.name,b);let P="",A="",M=!1,E=null,x=window.setTimeout(()=>{M=!0,b.kill()},e.timeout*1e3);b.stdout.on("data",_=>{if(E)return;let R=_.toString();if(P+=R,P.length>ho){E=`Run output exceeded the ${ho/(1024*1024)}MB stdout limit \u2014 process killed.`,b.kill();return}if(d&&n)for(let O of re(R)){let F=u.extractStreamChunk(O);F&&n(F)}}),b.stderr.on("data",_=>{E||(A+=_.toString(),A.length>po&&(E=`Run stderr exceeded the ${po/(1024*1024)}MB limit \u2014 process killed.`,b.kill()))}),b.on("error",_=>{window.clearTimeout(x),k(_)}),b.on("close",_=>{if(window.clearTimeout(x),this.runningProcesses.delete(e.name),E){k(new Error(E));return}let R=u.parseExecOutput(P,A,d);v({runId:l,prompt:o,exitCode:_,durationSeconds:Math.max(1,Math.round((Date.now()-y)/1e3)),stdout:P,stderr:A,outputText:R.outputText,rawJson:R.rawJson,tokensUsed:R.tokensUsed,costUsd:R.costUsd,toolsUsed:R.toolsUsed,timedOut:M,resolvedModel:c.value,modelSource:c.source,concreteModel:R.concreteModel,finalResult:R.finalResult})})})}finally{w?.restore(),Ut(g)}}};function fo(a){return z(a).slice(0,60)||"candidate"}function yo(a){let e=Math.max(400,a.tokenBudget*4);return[`You are running a nightly memory **reflection** for the agent "${a.agentName}".`,"Review the raw memory log and your current working memory below, then produce an","updated, consolidated working memory.","","Rules:","- Remove duplicates; resolve contradictions in favor of the NEWEST fact.","- File each fact under exactly one of: Preferences, Procedures, Observations.","- Keep durable user/process preferences, marking them `[pin]` \u2014 never drop or"," summarize pinned Preferences.","- Summarize the Observations section FROM THE RAW LOG (not from prior summaries)",` if needed so the whole memory fits within roughly ${a.tokenBudget} tokens`,` (~${e} characters).`,"- Note any friction that recurred across multiple runs as a skill candidate.","","Reply with EXACTLY these two fenced blocks and nothing else:","","```memory","## Preferences","- [pin] ","## Procedures","- ","## Observations","- ","```","","```candidates",'[{"key":"short-stable-key","pattern":"what recurred","evidence":["runs/..."],"suggestedSkill":"optional-name"}]',"```","","If there is nothing worth remembering, still emit a `memory` block preserving the","existing pinned Preferences. Use an empty array `[]` for candidates when none.","","\u2500\u2500 CURRENT WORKING MEMORY \u2500\u2500",a.workingMemoryBody.trim()||"(empty)","","\u2500\u2500 RAW MEMORY LOG (ground truth) \u2500\u2500",a.recentRaw.trim()||"(empty)",a.recentRunsSummary?` \u2500\u2500 RECENT ACTIVITY \u2500\u2500 -${r.recentRunsSummary.trim()}`:""].join(` -`)}function pi(r,t){let e=new RegExp("```"+t+"(?![A-Za-z0-9-])[^\\n]*\\n([\\s\\S]*?)```","i"),s=r.match(e);return s?s[1]??"":null}function fi(r){let t=pi(r,"memory"),e=t!==null?zs(t):null,s=[],n=pi(r,"candidates");if(n!==null&&n.trim())try{let a=JSON.parse(n.trim());Array.isArray(a)&&(s=a.map(i=>cc(i)).filter(i=>i!==null))}catch{}return{sections:e,candidates:s}}function cc(r){if(typeof r!="object"||r===null)return null;let t=r,e=typeof t.pattern=="string"?t.pattern.trim():"";return e?{key:typeof t.key=="string"&&t.key.trim()?ui(t.key):ui(e),pattern:e,evidence:Array.isArray(t.evidence)?t.evidence.filter(n=>typeof n=="string"):[],suggestedSkill:typeof t.suggestedSkill=="string"?t.suggestedSkill:void 0}:null}function gi(r,t,e){let s=new Map;for(let a of r)s.set(a.key,{...a,evidence:[...a.evidence]});let n=new Map;for(let a of t){let i=n.get(a.key);i?(i.evidence=Array.from(new Set([...i.evidence??[],...a.evidence??[]])),a.suggestedSkill&&(i.suggestedSkill=a.suggestedSkill)):n.set(a.key,{...a,evidence:[...a.evidence??[]]})}for(let a of n.values()){let i=s.get(a.key);i?(i.occurrences+=1,i.lastSeen=e,i.evidence=Array.from(new Set([...i.evidence,...a.evidence??[]])),a.suggestedSkill&&(i.suggestedSkill=a.suggestedSkill)):s.set(a.key,{key:a.key,pattern:a.pattern,occurrences:1,firstSeen:e,lastSeen:e,evidence:[...a.evidence??[]],proposed:!1,suggestedSkill:a.suggestedSkill})}return[...s.values()]}var dc=1.5,Gt=class r{constructor(t){this.store=t}static locks=new Map;static __resetLocksForTest(){r.locks.clear()}async capture(t,e,s,n){!t.memory||e.length===0||await this.withLock(t.name,()=>this.applyCaptures(t,e,s,n))}async drainPending(t,e){t.memory&&await this.withLock(t.name,async()=>{let s=await this.store.readAndClearPending(t.name),n=ci(s);if(n.length===0)return;let a=[];for(let i of n){let o=a[a.length-1],c={text:i.text,pinned:i.pinned,section:i.section};o&&o.source===i.source?o.entries.push(c):a.push({source:i.source,entries:[c]})}for(let i of a)await this.applyCaptures(t,i.entries,i.source,e)})}async reflect(t,e,s){return!t.memory||e===null||!e.some(n=>n.entries.length>0)?!1:(await this.withLock(t.name,async()=>{let n=this.store.getWorkingMemoryPath(t.name),a=await this.store.readWorkingMemory(t.name),i=br(a,e),o={filePath:a?.filePath??n,agent:t.name,schema:a?.schema??2,lastUpdated:s,lastReflection:s,tokenEstimate:0,sections:i};await this.store.writeWorkingMemory(t.name,o)}),!0)}async applyCaptures(t,e,s,n){let a=e.map(p=>({...p,text:pr(p.text)})).filter(p=>p.text);if(a.length===0)return;await this.store.migrateLegacyMemory?.(t.name);let i=this.store.getWorkingMemoryPath(t.name),o=await this.store.readWorkingMemory(t.name)??mr(i,t.name),c=n.slice(0,10),l=a.map(p=>`- ${n} [${s}]${p.pinned?" [pin]":""} ${p.text}`);await this.store.appendRawMemory(t.name,l,n);let h=new Set(o.sections.flatMap(p=>p.entries).map(p=>p.text.trim().toLowerCase())),d=o;for(let p of a){let m=p.text.trim().toLowerCase();if(h.has(m))continue;h.add(m);let f={text:p.text,source:s,date:c,pinned:p.pinned??!1},g=p.section??(p.pinned?"Preferences":hr);d=vr(d,[f],g,n)}let u=Math.ceil((t.memoryTokenBudget||0)*dc);if(u>0){let{wm:p,spilled:m}=wr(d,u);m.length>0&&console.info(`Agent Fleet: working memory for "${t.name}" exceeded hard cap; spilled ${m.length} entr${m.length===1?"y":"ies"} to raw-only (still in the archive). A reflection pass will consolidate.`),d=p}await this.store.writeWorkingMemory(t.name,d)}withLock(t,e){let n=(r.locks.get(t)??Promise.resolve()).then(e,e);return r.locks.set(t,n.then(()=>{},()=>{})),n}};var bs=Ye(require("crypto")),yi=Ye(require("https")),un=Ye(require("http")),vi=Ye(require("fs")),xa=Ye(require("path"));var pn=class{constructor(t){this.settings=t;this.settings}authManager;setAuthManager(t){this.authManager=t}async probeServer(t){try{if(t.type==="stdio"&&t.command)return(await this.probeStdioServer(t.command,t.args)).tools;if((t.type==="http"||t.type==="sse")&&t.url)return await this.probeHttpServer(t)}catch(e){console.warn(`McpManager: probe failed for ${t.name}:`,e)}return[]}async authenticateServer(t,e,s="http"){let n=await this.discoverOAuthMetadata(e);if(!n)throw new Error("Server does not support OAuth \u2014 no authorization metadata found.");let{metadata:a,resourceScopes:i,resource:o}=n;if(!a.registration_endpoint)throw new Error("Server does not support Dynamic Client Registration.");let c=await this.startOAuthCallbackServer(),l=`http://localhost:${c.port}/callback`;try{let h=await this.registerOAuthClient(a.registration_endpoint,l),d=bs.randomBytes(32).toString("base64url"),u=bs.createHash("sha256").update(d).digest("base64url"),p=bs.randomBytes(16).toString("hex"),m=new URLSearchParams({response_type:"code",client_id:h,code_challenge:u,code_challenge_method:"S256",redirect_uri:l,state:p,resource:o}),f=i??a.scopes_supported;f?.length&&m.set("scope",f.join(" "));let g=new URL(a.authorization_endpoint);for(let[w,S]of m)g.searchParams.set(w,S);let y=g.toString();Cr(y);let v=await c.waitForCode(p,18e4),k=await this.exchangeOAuthCode(a.token_endpoint,v,l,h,d);this.authManager?.storeOAuthToken(t,{accessToken:k.access_token,refreshToken:k.refresh_token,expiresAt:k.expires_in?Date.now()+k.expires_in*1e3:void 0,tokenEndpoint:a.token_endpoint,clientId:h,resource:o})}finally{c.close()}}async refreshProbeTokens(){if(!this.authManager)return;let t=this.authManager.getExpiringTokens();for(let[e,s]of t)if(s.refreshToken)try{let n=new URLSearchParams({grant_type:"refresh_token",refresh_token:s.refreshToken,client_id:s.clientId}).toString(),a=await this.oauthFetch(s.tokenEndpoint,"POST",n,"application/x-www-form-urlencoded");if(a.status===200){let i=JSON.parse(a.body),o=i.access_token;o&&this.authManager.storeOAuthToken(e,{accessToken:o,refreshToken:i.refresh_token??s.refreshToken,expiresAt:typeof i.expires_in=="number"?Date.now()+i.expires_in*1e3:void 0,tokenEndpoint:s.tokenEndpoint,clientId:s.clientId,resource:s.resource})}}catch(n){console.warn(`McpManager: failed to refresh token for ${e}:`,n)}}async discoverOAuthMetadata(t){let e=t.endsWith("/sse")?t.replace(/\/sse$/,"/mcp"):t,s=new URL(e),n;try{let d=await this.oauthFetch(e,"POST","{}","application/json");d.status===401&&(n=d.headers?.["www-authenticate"]?.match(/resource_metadata="([^"]+)"/)?.[1])}catch{}n||(n=`${s.origin}/.well-known/oauth-protected-resource${s.pathname}`);let a=s.origin,i,o=e;try{let d=await this.oauthFetch(n,"GET");if(d.status===200){let u=JSON.parse(d.body),p=u.authorization_servers;p?.[0]&&(a=p[0]),Array.isArray(u.scopes_supported)&&(i=u.scopes_supported),typeof u.resource=="string"&&(o=u.resource)}}catch{}let c=new URL(a),l=c.pathname==="/"?"":c.pathname,h=`${c.origin}/.well-known/oauth-authorization-server${l}`;try{let d=await this.oauthFetch(h,"GET");if(d.status===200)return{metadata:JSON.parse(d.body),resourceScopes:i,resource:o}}catch{}if(l){let d=`${c.origin}/.well-known/oauth-authorization-server`;try{let u=await this.oauthFetch(d,"GET");if(u.status===200)return{metadata:JSON.parse(u.body),resourceScopes:i,resource:o}}catch{}}return null}async registerOAuthClient(t,e){let s=JSON.stringify({client_name:"Agent Fleet Obsidian Plugin",redirect_uris:[e],grant_types:["authorization_code","refresh_token"],response_types:["code"],token_endpoint_auth_method:"none"}),n=await this.oauthFetch(t,"POST",s,"application/json");if(n.status!==200&&n.status!==201)throw new Error(`Client registration failed (HTTP ${n.status})`);let a=JSON.parse(n.body);if(!a.client_id)throw new Error("Client registration response missing client_id");return a.client_id}async startOAuthCallbackServer(){let t=null,e=null,s=un.createServer((a,i)=>{let o=new URL(a.url??"/","http://localhost");if(o.pathname!=="/callback"){i.writeHead(404),i.end();return}let c=o.searchParams.get("error");if(c){let d=o.searchParams.get("error_description")??c;i.writeHead(200,{"Content-Type":"text/html"}),i.end("

Authorization Failed

"+d+"

You can close this tab.

"),e?.(new Error(`OAuth denied: ${d}`));return}let l=o.searchParams.get("code"),h=o.searchParams.get("state");if(!l||!h){i.writeHead(400),i.end("Missing code or state");return}i.writeHead(200,{"Content-Type":"text/html"}),i.end("

Authenticated!

You can close this tab and return to Obsidian.

"),t?.(l,h)});return{port:await new Promise((a,i)=>{s.listen(0,"127.0.0.1",()=>{let o=s.address();if(!o||typeof o=="string"){i(new Error("Failed to bind callback server"));return}a(o.port)}),s.on("error",i)}),waitForCode:(a,i)=>new Promise((o,c)=>{let l=window.setTimeout(()=>{c(new Error("Authentication timed out \u2014 complete authorization in your browser and try again."))},i);t=(h,d)=>{window.clearTimeout(l),d!==a?c(new Error("OAuth state mismatch \u2014 possible CSRF attack")):o(h)},e=h=>{window.clearTimeout(l),c(h)}}),close:()=>{try{s.close()}catch{}}}}async exchangeOAuthCode(t,e,s,n,a){let i=new URLSearchParams({grant_type:"authorization_code",code:e,redirect_uri:s,client_id:n,code_verifier:a}).toString(),o=await this.oauthFetch(t,"POST",i,"application/x-www-form-urlencoded");if(o.status!==200)throw new Error(`Token exchange failed (HTTP ${o.status}): ${o.body}`);let c=JSON.parse(o.body);if(!c.access_token)throw new Error("Token response missing access_token");return c}oauthFetch(t,e,s,n){return new Promise((a,i)=>{let o=new URL(t),c=o.protocol==="https:",l={Accept:"application/json"};s&&(l["Content-Type"]=n??"application/json",l["Content-Length"]=String(Buffer.byteLength(s)));let h={hostname:o.hostname,port:o.port||(c?443:80),path:o.pathname+o.search,method:e,headers:l},u=(c?yi:un).request(h,m=>{let f="";m.on("data",g=>{f+=g.toString()}),m.on("end",()=>{let g={};for(let[y,v]of Object.entries(m.headers))typeof v=="string"?g[y]=v:Array.isArray(v)&&(g[y]=v.join(", "));a({status:m.statusCode??0,body:f,headers:g})})});u.on("error",i);let p=window.setTimeout(()=>{u.destroy(),i(new Error("OAuth request timed out"))},15e3);u.on("close",()=>window.clearTimeout(p)),s&&u.write(s),u.end()})}probeStdioServer(t,e){return new Promise(s=>{let n=e&&e.length>0?`${t} ${e.join(" ")}`:t,a=Sr(n,{env:{...process.env}}),i="",o,c=[],l=!1,h=!1,d=!1,u=()=>{l||(l=!0,a.kill(),s({description:o,tools:c}))},p=window.setTimeout(u,1e4);a.stdout.on("data",m=>{i+=m.toString();let f=ye(i);i=f.pop()??"";for(let g of f){let y=g.trim();if(y){try{let v=JSON.parse(y);if(v.id===1&&v.result){o=v.result.instructions??v.result.serverInfo?.description,h=!0;try{a.stdin.write(JSON.stringify({jsonrpc:"2.0",method:"notifications/initialized"})+` -`),a.stdin.write(JSON.stringify({jsonrpc:"2.0",id:2,method:"tools/list"})+` -`)}catch{window.clearTimeout(p),u();return}}else if(v.id===2&&v.result){for(let k of v.result.tools??[])c.push({name:k.name,description:k.description,inputSchema:k.inputSchema});d=!0}}catch{}h&&d&&(window.clearTimeout(p),u())}}}),a.on("error",()=>{window.clearTimeout(p),u()}),a.on("close",()=>{window.clearTimeout(p),u()});try{a.stdin.write(JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"agent-fleet",version:"1.0.0"}}})+` -`)}catch{window.clearTimeout(p),u()}})}async probeHttpServer(t){let e=await this.findServerToken(t);if(!e)return[];let s=t.url.endsWith("/sse")?t.url.replace(/\/sse$/,"/mcp"):t.url;try{let a=(await this.httpRequest(s,e,{jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2025-03-26",capabilities:{},clientInfo:{name:"agent-fleet",version:"1.0.0"}}}))?._sessionId;await this.httpRequest(s,e,{jsonrpc:"2.0",method:"notifications/initialized"},a);let i=await this.httpRequest(s,e,{jsonrpc:"2.0",id:2,method:"tools/list"},a),o=[],h=i?.result?.tools??[];for(let d of h)o.push({name:d.name,description:d.description,inputSchema:d.inputSchema});return o}catch(n){return console.warn(`McpManager: HTTP probe failed for ${t.name}:`,n),[]}}async findServerToken(t){if(this.authManager){let a=this.authManager.getToken(t.name);if(a)return a}let e=t.name.replace(/^claude\.ai\s+/i,"").replace(/\s+/g,"_").toUpperCase(),s=[`${e}_API_KEY`,`${e}_API_KEY_MILO`,`${e}_TOKEN`];for(let a of s){let i=process.env[a];if(i)return i}let n=[xa.join(ea(),".openclaw","workspace",".env"),xa.join(ea(),".env")];for(let a of n)try{let i=vi.readFileSync(a,"utf8");for(let o of ye(i)){let c=o.trim().match(/^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/);if(c){let l=c[1],h=c[2].replace(/^["']|["']$/g,"");if(s.includes(l))return h}}}catch{}}httpRequest(t,e,s,n){return new Promise((a,i)=>{let o=JSON.stringify(s),c=new URL(t),l=c.protocol==="https:",h={"Content-Type":"application/json",Accept:"application/json, text/event-stream",Authorization:`Bearer ${e}`,"Content-Length":String(Buffer.byteLength(o))};n&&(h["mcp-session-id"]=n);let d={hostname:c.hostname,port:c.port||(l?443:80),path:c.pathname+c.search,method:"POST",headers:h},p=(l?yi:un).request(d,f=>{let g="";f.on("data",y=>{g+=y.toString()}),f.on("end",()=>{let y=f.headers["mcp-session-id"];if((f.headers["content-type"]??"").includes("text/event-stream")){for(let k of ye(g))if(k.startsWith("data: "))try{let w=JSON.parse(k.slice(6));y&&(w._sessionId=y),a(w);return}catch{}a(null)}else try{let k=JSON.parse(g);y&&(k._sessionId=y),a(k)}catch{a(null)}})});p.on("error",i);let m=window.setTimeout(()=>{p.destroy(),a(null)},15e3);p.on("close",()=>window.clearTimeout(m)),p.write(o),p.end()})}};var hc={"every 5m":"*/5 * * * *","every 10m":"*/10 * * * *","every 15m":"*/15 * * * *","every 30m":"*/30 * * * *","every 1h":"0 * * * *","every 2h":"0 */2 * * *",hourly:"0 * * * *","daily at 9am":"0 9 * * *","daily at 6pm":"0 18 * * *","weekdays at 9am":"0 9 * * 1-5","weekly on monday":"0 9 * * 1","monthly on 1st":"0 9 1 * *"},mn=class{constructor(t,e){this.maxConcurrentRuns=t;this.callbacks=e}jobs=new Map;activeRuns=0;queue=[];paused=!1;setMaxConcurrentRuns(t){this.maxConcurrentRuns=t}parseSchedule(t){return hc[t.toLowerCase()]??t}toLocalISOString(t){let e=s=>String(s).padStart(2,"0");return`${t.getFullYear()}-${e(t.getMonth()+1)}-${e(t.getDate())}T${e(t.getHours())}:${e(t.getMinutes())}:${e(t.getSeconds())}`}async registerTask(t){if(this.unregisterTask(t.taskId),!(!t.enabled||t.type==="immediate"))try{if(t.type==="once"&&t.runAt){let e=new pe(new Date(t.runAt),{name:t.taskId,catch:!0},()=>{this.enqueue({task:t,reason:"scheduled"})});this.jobs.set(t.taskId,e),await this.callbacks.onTaskScheduled(t,t.runAt);return}if(t.type==="recurring"&&t.schedule){let e=this.parseSchedule(t.schedule),s=new pe(e,{name:t.taskId,catch:!0,protect:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},()=>{this.enqueue({task:t,reason:"scheduled"})});this.jobs.set(t.taskId,s);let n=s.nextRun();await this.callbacks.onTaskScheduled(t,n?this.toLocalISOString(n):void 0)}}catch(e){console.error(`Agent Fleet: Failed to register task "${t.taskId}":`,e)}}unregisterTask(t){let e=this.jobs.get(t);e&&(e.stop(),this.jobs.delete(t))}async loadTasks(t){for(let e of t)await this.registerTask(e)}async handleStartupCatchUp(t){let e=Date.now();for(let s of t)!s.enabled||!s.catchUp||!s.nextRun||new Date(s.nextRun).getTime()t.pause())}resumeAll(){this.paused=!1,this.jobs.forEach(t=>t.resume()),this.processQueue()}shutdown(){this.paused=!0,this.jobs.forEach(t=>t.stop()),this.jobs.clear(),this.queue.length=0}getQueueSize(){return this.queue.length}async processQueue(){if(!this.paused)for(;this.activeRuns0;){let t=this.queue.shift();if(!t)return;this.activeRuns+=1,this.callbacks.onTaskTriggered(t).finally(async()=>{this.activeRuns-=1,await this.processQueue()})}}};var ks=class r{constructor(t,e,s){this.repository=t;this.settings=e;this.executor=new hn(e,t,s),this.mcpManager=new pn(e),s&&this.mcpManager.setAuthManager(s),this.memoryWriter=new Gt(t),this.scheduler=new mn(e.maxConcurrentRuns,{onTaskTriggered:n=>this.runPendingTask(n),onTaskScheduled:(n,a)=>this.repository.updateTaskRunMetadata(n,{nextRun:a})})}scheduler;executor;mcpManager;snapshot={agents:[],skills:[],tasks:[],channels:[],mcpServers:[],validationIssues:[]};runtimeState=new Map;recentRuns=[];chartRuns=[];static CHART_WINDOW_DAYS=14;recentUsage=[];statusChangeListeners=new Set;runOutputListeners=new Map;runOutputBuffers=new Map;heartbeatJobs=new Map;reflectionJobs=new Map;reflectionsInFlight=new Set;reflectionRunning=0;reflectionWaiters=[];heartbeatRegisteredAt=0;heartbeatsInFlight=new Set;channelResultHandler;memoryWriter;async initialize(){this.snapshot=await this.repository.loadAll(),await this.repository.migrateAllLegacyMemory(),await this.refreshRunCaches();let t=this.snapshot.tasks.filter(e=>this.repository.getAgentByName(e.agent)?.enabled!==!1);await this.scheduler.loadTasks(t),await this.scheduler.handleStartupCatchUp(t),this.registerHeartbeats(),this.registerReflections(),this.emitStatusChange()}onChannelResult(t){this.channelResultHandler=t}async refreshFromVault(){this.snapshot=await this.repository.loadAll(),await this.rebuildSchedules(),await this.refreshRunCaches(),this.emitStatusChange()}getSnapshot(){return this.snapshot}getRecentRuns(){return this.recentRuns}getChartRuns(){return this.chartRuns}async refreshRunCaches(){this.recentRuns=await this.repository.listRecentRuns();let t=new Date;t.setDate(t.getDate()-(r.CHART_WINDOW_DAYS-1)),this.chartRuns=await this.repository.listRunsSince(t),this.recentUsage=await this.repository.readUsageSince(t)}getUsageRecords(){return this.recentUsage}recordUsage(t){this.recentUsage.push(t),this.repository.appendUsage(t).catch(e=>{console.warn("Agent Fleet: failed to append usage record",e)}),this.emitStatusChange()}getAgentState(t){let e=this.runtimeState.get(t),s=this.snapshot.agents.find(n=>n.name===t);return s&&!s.enabled?{status:"disabled",lastRun:e?.lastRun,currentRunId:e?.currentRunId}:e??{status:"idle"}}getFleetStatus(){let t=new Date,e=o=>String(o).padStart(2,"0"),s=`${t.getFullYear()}-${e(t.getMonth()+1)}-${e(t.getDate())}`,n=this.recentRuns.filter(o=>{let c=new Date(o.started);return`${c.getFullYear()}-${e(c.getMonth()+1)}-${e(c.getDate())}`===s}).length,a=Array.from(this.runtimeState.values()).filter(o=>o.status==="running").length,i=this.recentRuns.flatMap(o=>o.approvals??[]).filter(o=>o.status==="pending").length;return{running:a,pending:i,completedToday:n}}subscribe(t){return this.statusChangeListeners.add(t),()=>this.statusChangeListeners.delete(t)}onRunOutput(t,e){let s=this.runOutputListeners.get(t);s||(s=new Set,this.runOutputListeners.set(t,s)),s.add(e);let n=this.runOutputBuffers.get(t);return n&&e(n),()=>{this.runOutputListeners.get(t)?.delete(e)}}getRunOutputBuffer(t){return this.runOutputBuffers.get(t)??""}async handleVaultChange(t){await this.repository.loadFile(t),this.snapshot=this.repository.getSnapshot(),await this.rebuildSchedules(),await this.refreshRunCaches(),this.emitStatusChange()}async handleVaultDelete(t){this.repository.removeFile(t),this.snapshot=this.repository.getSnapshot(),await this.rebuildSchedules(),await this.refreshRunCaches(),this.emitStatusChange()}abortedAgents=new Set;abortAgentRun(t){let e=this.executor.abortAgent(t);return e&&(this.abortedAgents.add(t),this.runtimeState.set(t,{status:"idle"}),this.emitStatusChange()),e}wasAborted(t){return this.abortedAgents.has(t)}consumeAborted(t){let e=this.abortedAgents.has(t);return this.abortedAgents.delete(t),e}async runTaskNow(t,e){await this.scheduler.enqueue({task:{...t,type:"immediate"},reason:"manual",promptOverride:e})}async runAgentNow(t,e){let s=t.heartbeatBody.trim()&&e==="Run now and summarize the current state."?t.heartbeatBody.trim():e,n=s===t.heartbeatBody.trim()&&t.heartbeatBody.trim().length>0,a={filePath:"",taskId:n?`heartbeat-${Date.now()}`:`manual-${Date.now()}`,agent:t.name,type:"immediate",priority:"medium",enabled:!0,created:new Date().toISOString(),runCount:0,catchUp:!1,tags:n?[...t.tags,"heartbeat"]:t.tags,body:s};await this.scheduler.enqueue({task:a,reason:n?"heartbeat":"manual",promptOverride:s})}async resolveApproval(t,e,s){t.filePath&&(await this.repository.setApprovalDecision(t.filePath,e,s),await this.refreshRunCaches(),this.emitStatusChange())}async pruneOldRuns(){let t=Date.now()-this.settings.runLogRetentionDays*24*60*60*1e3,e=await this.repository.listRecentRuns(500);for(let s of e)s.filePath&&new Date(s.started).getTime()this.repository.getAgentByName(t.agent)?.enabled!==!1)),this.scheduler.resumeAll(),this.registerHeartbeats(),this.registerReflections()}shutdown(){for(let[,t]of this.heartbeatJobs)t.stop();this.heartbeatJobs.clear(),this.heartbeatsInFlight.clear();for(let[,t]of this.reflectionJobs)t.stop();this.reflectionJobs.clear(),this.reflectionsInFlight.clear(),this.scheduler.shutdown()}registerHeartbeats(){for(let[,t]of this.heartbeatJobs)t.stop();this.heartbeatJobs.clear(),this.heartbeatRegisteredAt=Date.now();for(let t of this.snapshot.agents)if(!(!t.enabled||!t.heartbeatEnabled||!t.heartbeatSchedule.trim()||!t.heartbeatBody.trim()))try{let e=new pe(t.heartbeatSchedule,{name:`heartbeat:${t.name}`,catch:!0,protect:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},()=>{this.runHeartbeat(t.name)});this.heartbeatJobs.set(t.name,e)}catch(e){console.error(`Agent Fleet: failed to register heartbeat for "${t.name}":`,e)}}async runHeartbeat(t){if(!(Date.now()-this.heartbeatRegisteredAt<1e4)&&!this.heartbeatsInFlight.has(t)){this.heartbeatsInFlight.add(t);try{let e=this.repository.getAgentByName(t);if(!e||!e.enabled||!e.heartbeatBody.trim())return;let s={filePath:"",taskId:`heartbeat-${Date.now()}`,agent:e.name,type:"immediate",priority:"medium",enabled:!0,created:new Date().toISOString(),runCount:0,catchUp:!1,tags:[...e.tags,"heartbeat"],body:e.heartbeatBody.trim()};await this.scheduler.enqueue({task:s,reason:"heartbeat",promptOverride:e.heartbeatBody.trim()})}finally{this.heartbeatsInFlight.delete(t)}}}getNextHeartbeat(t){let e=this.heartbeatJobs.get(t);return e?e.nextRun()??null:null}registerReflections(){for(let[,t]of this.reflectionJobs)t.stop();this.reflectionJobs.clear();for(let t of this.snapshot.agents){if(!t.enabled||!t.memory||!t.reflection.enabled)continue;let e=t.reflection.schedule.trim();if(e)try{let s=new pe(e,{name:`reflection:${t.name}`,catch:!0,protect:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},()=>{this.runReflection(t.name)});this.reflectionJobs.set(t.name,s)}catch(s){console.error(`Agent Fleet: failed to register reflection for "${t.name}":`,s)}}}getNextReflection(t){return this.reflectionJobs.get(t)?.nextRun()??null}async runReflectionNow(t){return this.runReflection(t)}async listPendingProposals(){return(await this.repository.listProposals()).filter(e=>e.status==="pending")}async acceptProposal(t){let e=await this.repository.readProposal(t);if(!e)return{ok:!1,message:"Proposal not found."};try{let s=await this.repository.applyProposal(e);return s?(await this.repository.setProposalStatus(t,"accepted"),await this.refreshFromVault(),{ok:!0,message:`Applied \u2192 ${s}`}):{ok:!1,message:"Couldn't apply \u2014 target skill not found; proposal left pending."}}catch(s){return{ok:!1,message:`Failed to apply: ${s instanceof Error?s.message:String(s)}`}}}async rejectProposal(t){await this.repository.setProposalStatus(t,"rejected"),this.emitStatusChange()}async withReflectionSlot(t){let e=Math.max(1,this.settings.maxConcurrentRuns);this.reflectionRunning>=e?await new Promise(s=>this.reflectionWaiters.push(s)):this.reflectionRunning++;try{return await t()}finally{let s=this.reflectionWaiters.shift();s?s():this.reflectionRunning--}}async runReflection(t){let e=this.repository.getAgentByName(t);if(!e||!e.enabled||!e.memory)return{ok:!1,message:"Agent not found, disabled, or memory off."};if(this.reflectionsInFlight.has(t))return{ok:!1,message:"A reflection is already in progress."};this.reflectionsInFlight.add(t);let s=new Date().toISOString(),n=s,a=`reflection-${Date.now()}`;this.runtimeState.set(t,{status:"running",currentTaskId:a,runStarted:s}),this.runOutputBuffers.set(t,""),this.emitStatusChange();try{await this.repository.migrateLegacyMemory(t);let i=await this.repository.readWorkingMemory(t),o=i?Ke(i.sections):"",c=await this.repository.readRecentRaw(t,2),l=mi({agentName:t,workingMemoryBody:o,recentRaw:c,tokenBudget:e.memoryTokenBudget}),h={filePath:"",taskId:a,agent:e.name,type:"immediate",priority:"low",enabled:!0,created:n,runCount:0,catchUp:!1,tags:[...e.tags,"reflection"],body:l,model:e.reflection.model||void 0},d=await this.withReflectionSlot(()=>this.executor.execute(e,h,l,v=>{let k=this.runOutputBuffers.get(t)??"";this.runOutputBuffers.set(t,k+v);let w=this.runOutputListeners.get(t);if(w)for(let S of w)S(v)},{suppressMemoryCapture:!0})),u=fi(d.outputText),p=await this.memoryWriter.reflect(e,u.sections,n);if(u.candidates.length>0){let v=await this.repository.readCandidates(t),k=gi(v,u.candidates,n);await this.repository.writeCandidates(t,k),await this.generateProposals(e,k,n)}let m=p?"Reflection complete \u2014 working memory consolidated.":"Reflection produced no memory block; working memory left unchanged.",f=d.exitCode===0||d.exitCode===null?"success":"failure",g={runId:d.runId,agent:e.name,task:a,status:f,started:s,completed:new Date().toISOString(),durationSeconds:d.durationSeconds,tokensUsed:d.tokensUsed,costUsd:d.costUsd,model:d.resolvedModel||e.reflection.model||e.model,modelSource:d.modelSource,concreteModel:d.concreteModel,exitCode:d.exitCode,tags:Array.from(new Set([...e.tags,"reflection"])),prompt:d.prompt,output:d.outputText,toolsUsed:d.toolsUsed.map(v=>`${v.tool}${v.command?`: ${v.command}`:""}`),finalResult:m,stderr:d.stderr},y=await this.repository.writeRunLog(g);return await this.refreshRunCaches(),this.runtimeState.set(t,{status:f==="success"?"idle":"error",currentRunId:d.runId,lastRun:{...g,filePath:y}}),{ok:p,message:m}}catch(i){let o=i instanceof Error?i.message:String(i);console.warn(`Agent Fleet: reflection failed for "${t}":`,i);let c={runId:(0,Sa.randomUUID)(),agent:t,task:a,status:"failure",started:s,completed:new Date().toISOString(),durationSeconds:Math.round((Date.now()-new Date(s).getTime())/1e3),model:e.reflection.model||e.model,exitCode:1,tags:Array.from(new Set([...e.tags,"reflection"])),prompt:"",output:`Reflection failed: ${o}`,toolsUsed:[]},l=c;try{let h=await this.repository.writeRunLog(c);await this.refreshRunCaches(),l={...c,filePath:h}}catch(h){console.warn(`Agent Fleet: failed to write reflection run log for "${t}"`,h)}return this.runtimeState.set(t,{status:"error",lastRun:l}),{ok:!1,message:`Reflection failed: ${o}`}}finally{this.reflectionsInFlight.delete(t),this.runOutputBuffers.delete(t),this.runOutputListeners.delete(t),this.emitStatusChange()}}async generateProposals(t,e,s){if(!t.reflection.proposeSkills)return;let n=t.reflection.recurrenceThreshold||3;for(let a of e){if(a.proposed||a.occurrencesHd(i)).filter(i=>i!==null))}catch{}return{sections:t,candidates:s}}function Hd(a){if(typeof a!="object"||a===null)return null;let e=a,t=typeof e.pattern=="string"?e.pattern.trim():"";return t?{key:typeof e.key=="string"&&e.key.trim()?fo(e.key):fo(t),pattern:t,evidence:Array.isArray(e.evidence)?e.evidence.filter(n=>typeof n=="string"):[],suggestedSkill:typeof e.suggestedSkill=="string"?e.suggestedSkill:void 0}:null}function wo(a,e,t){let s=new Map;for(let r of a)s.set(r.key,{...r,evidence:[...r.evidence]});let n=new Map;for(let r of e){let i=n.get(r.key);i?(i.evidence=Array.from(new Set([...i.evidence??[],...r.evidence??[]])),r.suggestedSkill&&(i.suggestedSkill=r.suggestedSkill)):n.set(r.key,{...r,evidence:[...r.evidence??[]]})}for(let r of n.values()){let i=s.get(r.key);i?(i.occurrences+=1,i.lastSeen=t,i.evidence=Array.from(new Set([...i.evidence,...r.evidence??[]])),r.suggestedSkill&&(i.suggestedSkill=r.suggestedSkill)):s.set(r.key,{key:r.key,pattern:r.pattern,occurrences:1,firstSeen:t,lastSeen:t,evidence:[...r.evidence??[]],proposed:!1,suggestedSkill:r.suggestedSkill})}return[...s.values()]}var Wd=1.5,fs=class a{constructor(e){this.store=e}static locks=new Map;static __resetLocksForTest(){a.locks.clear()}async capture(e,t,s,n){!e.memory||t.length===0||await this.withLock(e.name,()=>this.applyCaptures(e,t,s,n))}async drainPending(e,t){e.memory&&await this.withLock(e.name,async()=>{let s=await this.store.readAndClearPending(e.name),n=lo(s);if(n.length===0)return;let r=[];for(let i of n){let o=r[r.length-1],l={text:i.text,pinned:i.pinned,section:i.section};o&&o.source===i.source?o.entries.push(l):r.push({source:i.source,entries:[l]})}for(let i of r)await this.applyCaptures(e,i.entries,i.source,t)})}async reflect(e,t,s){return!e.memory||t===null||!t.some(n=>n.entries.length>0)?!1:(await this.withLock(e.name,async()=>{let n=this.store.getWorkingMemoryPath(e.name),r=await this.store.readWorkingMemory(e.name),i=bi(r,t),o={filePath:r?.filePath??n,agent:e.name,schema:r?.schema??2,lastUpdated:s,lastReflection:s,tokenEstimate:0,sections:i};await this.store.writeWorkingMemory(e.name,o)}),!0)}async applyCaptures(e,t,s,n){let r=t.map(p=>({...p,text:pi(p.text)})).filter(p=>p.text);if(r.length===0)return;await this.store.migrateLegacyMemory?.(e.name);let i=this.store.getWorkingMemoryPath(e.name),o=await this.store.readWorkingMemory(e.name)??mi(i,e.name),l=n.slice(0,10),c=r.map(p=>`- ${n} [${s}]${p.pinned?" [pin]":""} ${p.text}`);await this.store.appendRawMemory(e.name,c,n);let d=new Set(o.sections.flatMap(p=>p.entries).map(p=>p.text.trim().toLowerCase())),u=o;for(let p of r){let m=p.text.trim().toLowerCase();if(d.has(m))continue;d.add(m);let f={text:p.text,source:s,date:l,pinned:p.pinned??!1},g=p.section??(p.pinned?"Preferences":ui);u=vi(u,[f],g,n)}let h=Math.ceil((e.memoryTokenBudget||0)*Wd);if(h>0){let{wm:p,spilled:m}=wi(u,h);m.length>0&&console.info(`Agent Fleet: working memory for "${e.name}" exceeded hard cap; spilled ${m.length} entr${m.length===1?"y":"ies"} to raw-only (still in the archive). A reflection pass will consolidate.`),u=p}await this.store.writeWorkingMemory(e.name,u)}static LOCK_TIMEOUT_MS=1e4;withLock(e,t){let s=a.locks.get(e)??Promise.resolve(),n,r=new Promise(l=>{n=l}),i=()=>{let l=setTimeout(()=>{console.warn(`Agent Fleet: memory write for "${e}" still pending after ${a.LOCK_TIMEOUT_MS}ms \u2014 releasing its lock slot`),n()},a.LOCK_TIMEOUT_MS),c=t();return c.then(()=>{clearTimeout(l),n()},()=>{clearTimeout(l),n()}),c},o=s.then(i,i);return a.locks.set(e,r),o}};var Vs=st(require("crypto")),bo=st(require("https")),Jn=st(require("http")),ko=st(require("fs")),pr=st(require("path"));var Xn=class{constructor(e){this.settings=e;this.settings}authManager;setAuthManager(e){this.authManager=e}async probeServer(e){try{if(e.type==="stdio"&&e.command)return(await this.probeStdioServer(e.command,e.args)).tools;if((e.type==="http"||e.type==="sse")&&e.url)return await this.probeHttpServer(e)}catch(t){console.warn(`McpManager: probe failed for ${e.name}:`,t)}return[]}async authenticateServer(e,t,s="http"){let n=await this.discoverOAuthMetadata(t);if(!n)throw new Error("Server does not support OAuth \u2014 no authorization metadata found.");let{metadata:r,resourceScopes:i,resource:o}=n;if(!r.registration_endpoint)throw new Error("Server does not support Dynamic Client Registration.");let l=await this.startOAuthCallbackServer(),c=`http://localhost:${l.port}/callback`;try{let d=await this.registerOAuthClient(r.registration_endpoint,c),u=Vs.randomBytes(32).toString("base64url"),h=Vs.createHash("sha256").update(u).digest("base64url"),p=Vs.randomBytes(16).toString("hex"),m=new URLSearchParams({response_type:"code",client_id:d,code_challenge:h,code_challenge_method:"S256",redirect_uri:c,state:p,resource:o}),f=i??r.scopes_supported;f?.length&&m.set("scope",f.join(" "));let g=new URL(r.authorization_endpoint);for(let[k,b]of m)g.searchParams.set(k,b);let w=g.toString();_i(w);let y=await l.waitForCode(p,18e4),v=await this.exchangeOAuthCode(r.token_endpoint,y,c,d,u);this.authManager?.storeOAuthToken(e,{accessToken:v.access_token,refreshToken:v.refresh_token,expiresAt:v.expires_in?Date.now()+v.expires_in*1e3:void 0,tokenEndpoint:r.token_endpoint,clientId:d,resource:o})}finally{l.close()}}async refreshProbeTokens(){if(!this.authManager)return;let e=this.authManager.getExpiringTokens();for(let[t,s]of e)if(s.refreshToken)try{let n=new URLSearchParams({grant_type:"refresh_token",refresh_token:s.refreshToken,client_id:s.clientId}).toString(),r=await this.oauthFetch(s.tokenEndpoint,"POST",n,"application/x-www-form-urlencoded");if(r.status===200){let i=JSON.parse(r.body),o=i.access_token;o&&this.authManager.storeOAuthToken(t,{accessToken:o,refreshToken:i.refresh_token??s.refreshToken,expiresAt:typeof i.expires_in=="number"?Date.now()+i.expires_in*1e3:void 0,tokenEndpoint:s.tokenEndpoint,clientId:s.clientId,resource:s.resource})}}catch(n){console.warn(`McpManager: failed to refresh token for ${t}:`,n)}}async discoverOAuthMetadata(e){let t=e.endsWith("/sse")?e.replace(/\/sse$/,"/mcp"):e,s=new URL(t),n;try{let u=await this.oauthFetch(t,"POST","{}","application/json");u.status===401&&(n=u.headers?.["www-authenticate"]?.match(/resource_metadata="([^"]+)"/)?.[1])}catch{}n||(n=`${s.origin}/.well-known/oauth-protected-resource${s.pathname}`);let r=s.origin,i,o=t;try{let u=await this.oauthFetch(n,"GET");if(u.status===200){let h=JSON.parse(u.body),p=h.authorization_servers;p?.[0]&&(r=p[0]),Array.isArray(h.scopes_supported)&&(i=h.scopes_supported),typeof h.resource=="string"&&(o=h.resource)}}catch{}let l=new URL(r),c=l.pathname==="/"?"":l.pathname,d=`${l.origin}/.well-known/oauth-authorization-server${c}`;try{let u=await this.oauthFetch(d,"GET");if(u.status===200)return{metadata:JSON.parse(u.body),resourceScopes:i,resource:o}}catch{}if(c){let u=`${l.origin}/.well-known/oauth-authorization-server`;try{let h=await this.oauthFetch(u,"GET");if(h.status===200)return{metadata:JSON.parse(h.body),resourceScopes:i,resource:o}}catch{}}return null}async registerOAuthClient(e,t){let s=JSON.stringify({client_name:"Agent Fleet Obsidian Plugin",redirect_uris:[t],grant_types:["authorization_code","refresh_token"],response_types:["code"],token_endpoint_auth_method:"none"}),n=await this.oauthFetch(e,"POST",s,"application/json");if(n.status!==200&&n.status!==201)throw new Error(`Client registration failed (HTTP ${n.status})`);let r=JSON.parse(n.body);if(!r.client_id)throw new Error("Client registration response missing client_id");return r.client_id}async startOAuthCallbackServer(){let e=null,t=null,s=Jn.createServer((r,i)=>{try{let o=new URL(r.url??"/","http://localhost");if(o.pathname!=="/callback"){i.writeHead(404),i.end();return}let l=o.searchParams.get("error");if(l){let u=o.searchParams.get("error_description")??l;i.writeHead(200,{"Content-Type":"text/html"}),i.end("

Authorization Failed

"+u+"

You can close this tab.

"),t?.(new Error(`OAuth denied: ${u}`));return}let c=o.searchParams.get("code"),d=o.searchParams.get("state");if(!c||!d){i.writeHead(400),i.end("Missing code or state");return}i.writeHead(200,{"Content-Type":"text/html"}),i.end("

Authenticated!

You can close this tab and return to Obsidian.

"),e?.(c,d)}catch(o){try{i.headersSent||i.writeHead(500),i.end()}catch{}t?.(o instanceof Error?o:new Error(String(o)))}});return{port:await new Promise((r,i)=>{s.listen(0,"127.0.0.1",()=>{let o=s.address();if(!o||typeof o=="string"){i(new Error("Failed to bind callback server"));return}r(o.port)}),s.on("error",i)}),waitForCode:(r,i)=>new Promise((o,l)=>{let c=window.setTimeout(()=>{l(new Error("Authentication timed out \u2014 complete authorization in your browser and try again."))},i);e=(d,u)=>{window.clearTimeout(c),u!==r?l(new Error("OAuth state mismatch \u2014 possible CSRF attack")):o(d)},t=d=>{window.clearTimeout(c),l(d)}}),close:()=>{try{s.closeAllConnections()}catch{}try{s.close()}catch{}}}}async exchangeOAuthCode(e,t,s,n,r){let i=new URLSearchParams({grant_type:"authorization_code",code:t,redirect_uri:s,client_id:n,code_verifier:r}).toString(),o=await this.oauthFetch(e,"POST",i,"application/x-www-form-urlencoded");if(o.status!==200)throw new Error(`Token exchange failed (HTTP ${o.status}): ${o.body}`);let l=JSON.parse(o.body);if(!l.access_token)throw new Error("Token response missing access_token");return l}oauthFetch(e,t,s,n){return new Promise((r,i)=>{let o=new URL(e),l=o.protocol==="https:",c={Accept:"application/json"};s&&(c["Content-Type"]=n??"application/json",c["Content-Length"]=String(Buffer.byteLength(s)));let d={hostname:o.hostname,port:o.port||(l?443:80),path:o.pathname+o.search,method:t,headers:c},h=(l?bo:Jn).request(d,m=>{let f="";m.on("data",g=>{f+=g.toString()}),m.on("end",()=>{let g={};for(let[w,y]of Object.entries(m.headers))typeof y=="string"?g[w]=y:Array.isArray(y)&&(g[w]=y.join(", "));r({status:m.statusCode??0,body:f,headers:g})})});h.on("error",i);let p=window.setTimeout(()=>{try{h.destroy()}catch{}i(new Error("OAuth request timed out"))},15e3);h.on("close",()=>window.clearTimeout(p)),s&&h.write(s),h.end()})}probeStdioServer(e,t){return new Promise(s=>{let n=t&&t.length>0?`${e} ${t.join(" ")}`:e,r=Ti(n,{env:{...process.env}}),i="",o,l=[],c=!1,d=!1,u=!1,h=()=>{c||(c=!0,r.kill(),s({description:o,tools:l}))},p=window.setTimeout(h,1e4);r.stdout.on("data",m=>{i+=m.toString();let f=re(i);i=f.pop()??"";for(let g of f){let w=g.trim();if(w){try{let y=JSON.parse(w);if(y.id===1&&y.result){o=y.result.instructions??y.result.serverInfo?.description,d=!0;try{r.stdin.write(JSON.stringify({jsonrpc:"2.0",method:"notifications/initialized"})+` +`),r.stdin.write(JSON.stringify({jsonrpc:"2.0",id:2,method:"tools/list"})+` +`)}catch{window.clearTimeout(p),h();return}}else if(y.id===2&&y.result){for(let v of y.result.tools??[])l.push({name:v.name,description:v.description,inputSchema:v.inputSchema});u=!0}}catch{}d&&u&&(window.clearTimeout(p),h())}}}),r.on("error",()=>{window.clearTimeout(p),h()}),r.on("close",()=>{window.clearTimeout(p),h()});try{r.stdin.write(JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"agent-fleet",version:"1.0.0"}}})+` +`)}catch{window.clearTimeout(p),h()}})}async probeHttpServer(e){let t=await this.findServerToken(e);if(!t)return[];let s=e.url.endsWith("/sse")?e.url.replace(/\/sse$/,"/mcp"):e.url;try{let r=(await this.httpRequest(s,t,{jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2025-03-26",capabilities:{},clientInfo:{name:"agent-fleet",version:"1.0.0"}}}))?._sessionId;await this.httpRequest(s,t,{jsonrpc:"2.0",method:"notifications/initialized"},r);let i=await this.httpRequest(s,t,{jsonrpc:"2.0",id:2,method:"tools/list"},r),o=[],d=i?.result?.tools??[];for(let u of d)o.push({name:u.name,description:u.description,inputSchema:u.inputSchema});return o}catch(n){return console.warn(`McpManager: HTTP probe failed for ${e.name}:`,n),[]}}async findServerToken(e){if(this.authManager){let r=this.authManager.getToken(e.name);if(r)return r}let t=e.name.replace(/^claude\.ai\s+/i,"").replace(/\s+/g,"_").toUpperCase(),s=[`${t}_API_KEY`,`${t}_API_KEY_MILO`,`${t}_TOKEN`];for(let r of s){let i=process.env[r];if(i)return i}let n=[pr.join(qa(),".openclaw","workspace",".env"),pr.join(qa(),".env")];for(let r of n)try{let i=ko.readFileSync(r,"utf8");for(let o of re(i)){let l=o.trim().match(/^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/);if(l){let c=l[1],d=l[2].replace(/^["']|["']$/g,"");if(s.includes(c))return d}}}catch{}}httpRequest(e,t,s,n){return new Promise((r,i)=>{let o=JSON.stringify(s),l=new URL(e),c=l.protocol==="https:",d={"Content-Type":"application/json",Accept:"application/json, text/event-stream",Authorization:`Bearer ${t}`,"Content-Length":String(Buffer.byteLength(o))};n&&(d["mcp-session-id"]=n);let u={hostname:l.hostname,port:l.port||(c?443:80),path:l.pathname+l.search,method:"POST",headers:d},p=(c?bo:Jn).request(u,f=>{let g="";f.on("data",w=>{g+=w.toString()}),f.on("end",()=>{let w=f.headers["mcp-session-id"];if((f.headers["content-type"]??"").includes("text/event-stream")){for(let v of re(g))if(v.startsWith("data: "))try{let k=JSON.parse(v.slice(6));w&&(k._sessionId=w),r(k);return}catch{}r(null)}else try{let v=JSON.parse(g);w&&(v._sessionId=w),r(v)}catch{r(null)}})});p.on("error",i);let m=window.setTimeout(()=>{p.destroy(),r(null)},15e3);p.on("close",()=>window.clearTimeout(m)),p.write(o),p.end()})}};var qd={"every 5m":"*/5 * * * *","every 10m":"*/10 * * * *","every 15m":"*/15 * * * *","every 30m":"*/30 * * * *","every 1h":"0 * * * *","every 2h":"0 */2 * * *",hourly:"0 * * * *","daily at 9am":"0 9 * * *","daily at 6pm":"0 18 * * *","weekdays at 9am":"0 9 * * 1-5","weekly on monday":"0 9 * * 1","monthly on 1st":"0 9 1 * *"},Qn=class{constructor(e,t){this.maxConcurrentRuns=e;this.callbacks=t}jobs=new Map;activeRuns=0;queue=[];paused=!1;setMaxConcurrentRuns(e){this.maxConcurrentRuns=e}parseSchedule(e){return qd[e.toLowerCase()]??e}toLocalISOString(e){let t=s=>String(s).padStart(2,"0");return`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}T${t(e.getHours())}:${t(e.getMinutes())}:${t(e.getSeconds())}`}async registerTask(e){if(this.unregisterTask(e.taskId),!(!e.enabled||e.type==="immediate"))try{if(e.type==="once"&&e.runAt){let t=new ie(new Date(e.runAt),{name:e.taskId,catch:!0},()=>{this.enqueue({task:e,reason:"scheduled"})});this.jobs.set(e.taskId,t),await this.callbacks.onTaskScheduled(e,e.runAt);return}if(e.type==="recurring"&&e.schedule){let t=this.parseSchedule(e.schedule),s=new ie(t,{name:e.taskId,catch:!0,protect:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},()=>{this.enqueue({task:e,reason:"scheduled"})});this.jobs.set(e.taskId,s);let n=s.nextRun();await this.callbacks.onTaskScheduled(e,n?this.toLocalISOString(n):void 0)}}catch(t){console.error(`Agent Fleet: Failed to register task "${e.taskId}":`,t)}}unregisterTask(e){let t=this.jobs.get(e);t&&(t.stop(),this.jobs.delete(e))}async loadTasks(e){for(let t of e)await this.registerTask(t)}async handleStartupCatchUp(e){let t=Date.now();for(let s of e)!s.enabled||!s.catchUp||!s.nextRun||new Date(s.nextRun).getTime()e.pause())}resumeAll(){this.paused=!1,this.jobs.forEach(e=>e.resume()),this.processQueue()}shutdown(){this.paused=!0,this.jobs.forEach(e=>e.stop()),this.jobs.clear(),this.queue.length=0}getQueueSize(){return this.queue.length}async processQueue(){if(!this.paused)for(;this.activeRuns0;){let e=this.queue.shift();if(!e)return;this.activeRuns+=1,this.callbacks.onTaskTriggered(e).finally(async()=>{this.activeRuns-=1,await this.processQueue()})}}};var Ys=class a{constructor(e,t,s){this.repository=e;this.settings=t;this.executor=new Kn(t,e,s),this.mcpManager=new Xn(t),s&&this.mcpManager.setAuthManager(s),this.memoryWriter=new fs(e),this.scheduler=new Qn(t.maxConcurrentRuns,{onTaskTriggered:n=>this.runPendingTask(n),onTaskScheduled:(n,r)=>this.repository.updateTaskRunMetadata(n,{nextRun:r})})}scheduler;executor;mcpManager;snapshot={agents:[],skills:[],tasks:[],channels:[],mcpServers:[],validationIssues:[]};runtimeState=new Map;recentRuns=[];chartRuns=[];static CHART_WINDOW_DAYS=14;recentUsage=[];statusChangeListeners=new Set;runOutputListeners=new Map;runOutputBuffers=new Map;runOutputPending=new Map;runOutputFlushTimers=new Map;static OUTPUT_FLUSH_INTERVAL_MS=100;fleetStatusCache=null;heartbeatJobs=new Map;reflectionJobs=new Map;reflectionsInFlight=new Set;reflectionRunning=0;reflectionWaiters=[];heartbeatRegisteredAt=0;heartbeatsInFlight=new Set;channelResultHandler;memoryWriter;async initialize(){this.snapshot=await this.repository.loadAll(),await this.repository.migrateAllLegacyMemory(),await this.refreshRunCaches();let e=this.snapshot.tasks.filter(t=>this.repository.getAgentByName(t.agent)?.enabled!==!1);await this.scheduler.loadTasks(e),await this.scheduler.handleStartupCatchUp(e),this.registerHeartbeats(),this.registerReflections(),this.emitStatusChange()}onChannelResult(e){this.channelResultHandler=e}async refreshFromVault(){this.snapshot=await this.repository.loadAll(),await this.rebuildSchedules(),await this.refreshRunCaches(),this.emitStatusChange()}getSnapshot(){return this.snapshot}getRecentRuns(){return this.recentRuns}getChartRuns(){return this.chartRuns}async refreshRunCaches(){this.recentRuns=await this.repository.listRecentRuns();let e=new Date;e.setDate(e.getDate()-(a.CHART_WINDOW_DAYS-1)),this.chartRuns=await this.repository.listRunsSince(e),this.recentUsage=await this.repository.readUsageSince(e)}getUsageRecords(){return this.recentUsage}recordUsage(e){this.recentUsage.push(e),this.repository.appendUsage(e).catch(t=>{console.warn("Agent Fleet: failed to append usage record",t)}),this.emitStatusChange()}getAgentState(e){let t=this.runtimeState.get(e),s=this.snapshot.agents.find(n=>n.name===e);return s&&!s.enabled?{status:"disabled",lastRun:t?.lastRun,currentRunId:t?.currentRunId}:t??{status:"idle"}}getFleetStatus(){let e=new Date,t=i=>String(i).padStart(2,"0"),s=`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}`,n=this.fleetStatusCache;if(!n||n.runs!==this.recentRuns||n.today!==s){let i=this.recentRuns.filter(l=>{let c=new Date(l.started);return`${c.getFullYear()}-${t(c.getMonth()+1)}-${t(c.getDate())}`===s}).length,o=this.recentRuns.flatMap(l=>l.approvals??[]).filter(l=>l.status==="pending").length;n={runs:this.recentRuns,today:s,completedToday:i,pending:o},this.fleetStatusCache=n}return{running:Array.from(this.runtimeState.values()).filter(i=>i.status==="running").length,pending:n.pending,completedToday:n.completedToday}}subscribe(e){return this.statusChangeListeners.add(e),()=>this.statusChangeListeners.delete(e)}onRunOutput(e,t){this.flushRunOutput(e);let s=this.runOutputListeners.get(e);s||(s=new Set,this.runOutputListeners.set(e,s)),s.add(t);let n=this.runOutputBuffers.get(e);return n&&t(n),()=>{this.runOutputListeners.get(e)?.delete(t)}}getRunOutputBuffer(e){return this.runOutputBuffers.get(e)??""}emitRunOutput(e,t){this.runOutputBuffers.set(e,(this.runOutputBuffers.get(e)??"")+t),this.runOutputPending.set(e,(this.runOutputPending.get(e)??"")+t),this.runOutputFlushTimers.has(e)||this.runOutputFlushTimers.set(e,setTimeout(()=>this.flushRunOutput(e),a.OUTPUT_FLUSH_INTERVAL_MS))}flushRunOutput(e){let t=this.runOutputFlushTimers.get(e);t!==void 0&&(clearTimeout(t),this.runOutputFlushTimers.delete(e));let s=this.runOutputPending.get(e);if(!s)return;this.runOutputPending.delete(e);let n=this.runOutputListeners.get(e);if(n)for(let r of n)r(s)}resetRunOutput(e){let t=this.runOutputFlushTimers.get(e);t!==void 0&&(clearTimeout(t),this.runOutputFlushTimers.delete(e)),this.runOutputPending.delete(e),this.runOutputBuffers.set(e,"")}clearRunOutput(e){this.flushRunOutput(e),this.runOutputBuffers.delete(e),this.runOutputListeners.delete(e)}async handleVaultChange(e){await this.repository.loadFile(e),this.snapshot=this.repository.getSnapshot(),await this.rebuildSchedules(),await this.refreshRunCaches(),this.emitStatusChange()}async handleVaultDelete(e){this.repository.removeFile(e),this.snapshot=this.repository.getSnapshot(),await this.rebuildSchedules(),await this.refreshRunCaches(),this.emitStatusChange()}abortedAgents=new Set;abortAgentRun(e){let t=this.executor.abortAgent(e);return t&&(this.abortedAgents.add(e),this.runtimeState.set(e,{status:"idle"}),this.emitStatusChange()),t}wasAborted(e){return this.abortedAgents.has(e)}consumeAborted(e){let t=this.abortedAgents.has(e);return this.abortedAgents.delete(e),t}async runTaskNow(e,t){await this.scheduler.enqueue({task:{...e,type:"immediate"},reason:"manual",promptOverride:t})}async runAgentNow(e,t){let s=e.heartbeatBody.trim()&&t==="Run now and summarize the current state."?e.heartbeatBody.trim():t,n=s===e.heartbeatBody.trim()&&e.heartbeatBody.trim().length>0,r={filePath:"",taskId:n?`heartbeat-${Date.now()}`:`manual-${Date.now()}`,agent:e.name,type:"immediate",priority:"medium",enabled:!0,created:new Date().toISOString(),runCount:0,catchUp:!1,tags:n?[...e.tags,"heartbeat"]:e.tags,body:s};await this.scheduler.enqueue({task:r,reason:n?"heartbeat":"manual",promptOverride:s})}async resolveApproval(e,t,s){e.filePath&&(await this.repository.setApprovalDecision(e.filePath,t,s),await this.refreshRunCaches(),this.emitStatusChange())}async pruneOldRuns(){let e=Date.now()-this.settings.runLogRetentionDays*24*60*60*1e3,t=await this.repository.listRecentRuns(500);for(let s of t)s.filePath&&new Date(s.started).getTime()this.repository.getAgentByName(e.agent)?.enabled!==!1)),this.scheduler.resumeAll(),this.registerHeartbeats(),this.registerReflections()}shutdown(){for(let[,e]of this.heartbeatJobs)e.stop();this.heartbeatJobs.clear(),this.heartbeatsInFlight.clear();for(let[,e]of this.reflectionJobs)e.stop();this.reflectionJobs.clear(),this.reflectionsInFlight.clear();for(let[,e]of this.runOutputFlushTimers)clearTimeout(e);this.runOutputFlushTimers.clear(),this.runOutputPending.clear(),this.scheduler.shutdown()}registerHeartbeats(){for(let[,e]of this.heartbeatJobs)e.stop();this.heartbeatJobs.clear(),this.heartbeatRegisteredAt=Date.now();for(let e of this.snapshot.agents)if(!(!e.enabled||!e.heartbeatEnabled||!e.heartbeatSchedule.trim()||!e.heartbeatBody.trim()))try{let t=new ie(e.heartbeatSchedule,{name:`heartbeat:${e.name}`,catch:!0,protect:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},()=>{this.runHeartbeat(e.name)});this.heartbeatJobs.set(e.name,t)}catch(t){console.error(`Agent Fleet: failed to register heartbeat for "${e.name}":`,t)}}async runHeartbeat(e){if(Date.now()-this.heartbeatRegisteredAt<1e4||this.heartbeatsInFlight.has(e))return;this.heartbeatsInFlight.add(e);let t=!1;try{let s=this.repository.getAgentByName(e);if(!s||!s.enabled||!s.heartbeatBody.trim())return;let n={filePath:"",taskId:`heartbeat-${Date.now()}`,agent:s.name,type:"immediate",priority:"medium",enabled:!0,created:new Date().toISOString(),runCount:0,catchUp:!1,tags:[...s.tags,"heartbeat"],body:s.heartbeatBody.trim()};await this.scheduler.enqueue({task:n,reason:"heartbeat",promptOverride:s.heartbeatBody.trim()}),t=!0}finally{t||this.heartbeatsInFlight.delete(e)}}getNextHeartbeat(e){let t=this.heartbeatJobs.get(e);return t?t.nextRun()??null:null}registerReflections(){for(let[,e]of this.reflectionJobs)e.stop();this.reflectionJobs.clear();for(let e of this.snapshot.agents){if(!e.enabled||!e.memory||!e.reflection.enabled)continue;let t=e.reflection.schedule.trim();if(t)try{let s=new ie(t,{name:`reflection:${e.name}`,catch:!0,protect:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},()=>{this.runReflection(e.name)});this.reflectionJobs.set(e.name,s)}catch(s){console.error(`Agent Fleet: failed to register reflection for "${e.name}":`,s)}}}getNextReflection(e){return this.reflectionJobs.get(e)?.nextRun()??null}async runReflectionNow(e){return this.runReflection(e)}async listPendingProposals(){return(await this.repository.listProposals()).filter(t=>t.status==="pending")}async acceptProposal(e){let t=await this.repository.readProposal(e);if(!t)return{ok:!1,message:"Proposal not found."};try{let s=await this.repository.applyProposal(t);return s?(await this.repository.setProposalStatus(e,"accepted"),await this.refreshFromVault(),{ok:!0,message:`Applied \u2192 ${s}`}):{ok:!1,message:"Couldn't apply \u2014 target skill not found; proposal left pending."}}catch(s){return{ok:!1,message:`Failed to apply: ${s instanceof Error?s.message:String(s)}`}}}async rejectProposal(e){await this.repository.setProposalStatus(e,"rejected"),this.emitStatusChange()}async withReflectionSlot(e){let t=Math.max(1,this.settings.maxConcurrentRuns);this.reflectionRunning>=t?await new Promise(s=>this.reflectionWaiters.push(s)):this.reflectionRunning++;try{return await e()}finally{let s=this.reflectionWaiters.shift();s?s():this.reflectionRunning--}}async runReflection(e){let t=this.repository.getAgentByName(e);if(!t||!t.enabled||!t.memory)return{ok:!1,message:"Agent not found, disabled, or memory off."};if(this.reflectionsInFlight.has(e))return{ok:!1,message:"A reflection is already in progress."};this.reflectionsInFlight.add(e);let s=new Date().toISOString(),n=s,r=`reflection-${Date.now()}`;this.runtimeState.set(e,{status:"running",currentTaskId:r,runStarted:s}),this.resetRunOutput(e),this.emitStatusChange();try{await this.repository.migrateLegacyMemory(e);let i=await this.repository.readWorkingMemory(e),o=i?at(i.sections):"",l=await this.repository.readRecentRaw(e,2),c=yo({agentName:e,workingMemoryBody:o,recentRaw:l,tokenBudget:t.memoryTokenBudget}),d={filePath:"",taskId:r,agent:t.name,type:"immediate",priority:"low",enabled:!0,created:n,runCount:0,catchUp:!1,tags:[...t.tags,"reflection"],body:c,model:t.reflection.model||void 0},u=await this.withReflectionSlot(()=>this.executor.execute(t,d,c,y=>this.emitRunOutput(e,y),{suppressMemoryCapture:!0})),h=vo(u.outputText),p=await this.memoryWriter.reflect(t,h.sections,n);if(h.candidates.length>0){let y=await this.repository.readCandidates(e),v=wo(y,h.candidates,n);await this.repository.writeCandidates(e,v),await this.generateProposals(t,v,n)}let m=p?"Reflection complete \u2014 working memory consolidated.":"Reflection produced no memory block; working memory left unchanged.",f=u.exitCode===0||u.exitCode===null?"success":"failure",g={runId:u.runId,agent:t.name,task:r,status:f,started:s,completed:new Date().toISOString(),durationSeconds:u.durationSeconds,tokensUsed:u.tokensUsed,costUsd:u.costUsd,model:u.resolvedModel||t.reflection.model||t.model,modelSource:u.modelSource,concreteModel:u.concreteModel,exitCode:u.exitCode,tags:Array.from(new Set([...t.tags,"reflection"])),prompt:u.prompt,output:u.outputText,toolsUsed:u.toolsUsed.map(y=>`${y.tool}${y.command?`: ${y.command}`:""}`),finalResult:m,stderr:u.stderr},w=await this.repository.writeRunLog(g);return await this.refreshRunCaches(),this.runtimeState.set(e,{status:f==="success"?"idle":"error",currentRunId:u.runId,lastRun:{...g,filePath:w}}),f==="failure"&&this.notify(g),{ok:p,message:m}}catch(i){let o=i instanceof Error?i.message:String(i);console.warn(`Agent Fleet: reflection failed for "${e}":`,i);let l={runId:(0,mr.randomUUID)(),agent:e,task:r,status:"failure",started:s,completed:new Date().toISOString(),durationSeconds:Math.round((Date.now()-new Date(s).getTime())/1e3),model:t.reflection.model||t.model,exitCode:1,tags:Array.from(new Set([...t.tags,"reflection"])),prompt:"",output:`Reflection failed: ${o}`,toolsUsed:[]},c=l;try{let d=await this.repository.writeRunLog(l);await this.refreshRunCaches(),c={...l,filePath:d}}catch(d){console.warn(`Agent Fleet: failed to write reflection run log for "${e}"`,d)}return this.runtimeState.set(e,{status:"error",lastRun:c}),this.notify(l),{ok:!1,message:`Reflection failed: ${o}`}}finally{this.reflectionsInFlight.delete(e),this.clearRunOutput(e),this.emitStatusChange()}}async generateProposals(e,t,s){if(!e.reflection.proposeSkills)return;let n=e.reflection.recurrenceThreshold||3;for(let r of t){if(r.proposed||r.occurrences ${a.pattern} +> ${r.pattern} -Document the reliable steps to handle this so future runs don't rediscover them.`};try{await this.repository.writeProposal(o),a.proposed=!0,await this.repository.writeCandidates(t.name,e)}catch(c){console.warn(`Agent Fleet: failed to write proposal for "${t.name}"`,c)}}}async runPendingTask({task:t,promptOverride:e}){let s=this.repository.getAgentByName(t.agent);if(!s||!s.enabled)return;let n=new Date().toISOString();this.runtimeState.set(s.name,{status:"running",currentTaskId:t.taskId,runStarted:n}),this.runOutputBuffers.set(s.name,""),this.emitStatusChange();try{let a=await this.executor.execute(s,t,e,m=>{let f=this.runOutputBuffers.get(s.name)??"";this.runOutputBuffers.set(s.name,f+m);let g=this.runOutputListeners.get(s.name);if(g)for(let y of g)y(m)}),i=this.consumeAborted(s.name),o=i?[]:this.buildApprovals(s,a.toolsUsed),c=i?"cancelled":this.resolveRunStatus(a,o),l={runId:a.runId,agent:s.name,task:t.taskId,status:c,started:n,completed:new Date().toISOString(),durationSeconds:a.durationSeconds,tokensUsed:a.tokensUsed,costUsd:a.costUsd,model:a.resolvedModel||s.model,modelSource:a.modelSource,concreteModel:a.concreteModel,exitCode:a.exitCode,tags:Array.from(new Set([...s.tags,...t.tags])),prompt:a.prompt,output:a.outputText,toolsUsed:a.toolsUsed.map(m=>`${m.tool}${m.command?`: ${m.command}`:""}`),finalResult:a.finalResult,stderr:a.stderr,approvals:o},h=await this.repository.writeRunLog(l);if(await this.repository.updateTaskRunMetadata(t,{lastRun:n,runCount:t.runCount+1}),s.memory){let f=t.tags.includes("heartbeat")?"heartbeat":`task:${t.taskId}`,g=qs(a.outputText);try{await this.memoryWriter.capture(s,g,f,new Date().toISOString())}catch(y){console.warn(`Agent Fleet: failed to append memory for "${s.name}"`,y)}}let d=t.tags.includes("heartbeat"),u=d?s.heartbeatChannel:t.channel??"",p=d?s.heartbeatChannelTarget??"":t.channelTarget??"";if(u&&!i&&l.output.trim())try{this.channelResultHandler?.(s.name,u,l.output,d?"heartbeat":t.taskId,p)}catch(m){console.warn(`Agent Fleet: channel delivery failed for ${s.name}`,m)}i&&(l.output="Task was manually stopped."),await this.refreshRunCaches(),this.runtimeState.set(s.name,{status:i||c==="success"?"idle":c==="pending_approval"?"pending":"error",currentRunId:a.runId,lastRun:{...l,filePath:h}}),i||this.notify(l)}catch(a){let i=this.consumeAborted(s.name),o=i?"cancelled":"failure",c=It(t,s,this.settings),l={runId:(0,Sa.randomUUID)(),agent:s.name,task:t.taskId,status:o,started:n,completed:new Date().toISOString(),durationSeconds:Math.round((Date.now()-new Date(n).getTime())/1e3),model:c.value||s.model,modelSource:c.source,exitCode:i?-1:1,tags:Array.from(new Set([...s.tags,...t.tags])),prompt:e??t.body,output:i?"Task was manually stopped.":a instanceof Error?a.message:String(a),toolsUsed:[]},h=await this.repository.writeRunLog(l);await this.refreshRunCaches(),this.runtimeState.set(s.name,{status:i?"idle":"error",lastRun:{...l,filePath:h}}),i||this.notify(l)}finally{if(s.memory)try{await this.memoryWriter.drainPending(s,new Date().toISOString())}catch(a){console.warn(`Agent Fleet: failed to drain pending memory for "${s.name}"`,a)}this.runOutputBuffers.delete(s.name),this.runOutputListeners.delete(s.name),this.emitStatusChange()}}buildApprovals(t,e){let s=e.filter(n=>t.approvalRequired.includes(n.tool)).map(n=>({tool:n.tool,command:n.command,reason:n.reason,status:"pending"}));return s.length>0?s:void 0}resolveRunStatus(t,e){return e?.length?"pending_approval":t.timedOut?"timeout":t.exitCode===0?"success":"failure"}notify(t){if(this.settings.notificationLevel==="none"||this.settings.notificationLevel==="failures-only"&&t.status==="success")return;let s=(ye(t.output).map(a=>a.trim()).find(a=>a&&!a.startsWith("{")&&!a.startsWith("["))??"").slice(0,120)||t.status,n=t.status==="success"?`\u2705 ${t.agent}: ${s}`:t.status==="pending_approval"?`\u{1F535} ${t.agent} needs approval: ${(t.approvals??[])[0]?.tool??"tool action"}`:`\u274C ${t.agent}: ${s}`;new wi.Notice(n,t.status==="success"?5e3:0)}emitStatusChange(){for(let t of this.statusChangeListeners)t()}};function uc(r){return r.toLowerCase().replace(/[^a-z0-9-]/g,"-").replace(/-{2,}/g,"-").replace(/^-|-$/g,"")}function xs(r,t){return`${r}-${uc(t)}`}var Vt="af-channel-cred",Ss="af-mcp-secret",fn=class{constructor(t){this.storage=t;t||console.warn("Agent Fleet: SecretStorage unavailable (Obsidian < 1.11.4). Secrets will use plaintext fallback.")}get available(){return!!this.storage}setJson(t,e,s){if(!this.storage)return;let n=xs(t,e);this.storage.setSecret(n,JSON.stringify(s))}getJson(t,e){if(!this.storage)return null;let s=xs(t,e),n=this.storage.getSecret(s);if(!n)return null;try{return JSON.parse(n)}catch{return null}}setString(t,e,s){if(!this.storage)return;let n=xs(t,e);this.storage.setSecret(n,s)}getString(t,e){if(!this.storage)return null;let s=xs(t,e);return this.storage.getSecret(s)||null}delete(t,e){if(!this.storage)return;let s=xs(t,e);this.storage.setSecret(s,"")}listByPrefix(t){return this.storage?this.storage.listSecrets().filter(e=>e.startsWith(t+"-")):[]}};var gn=class{tokens=new Map;oauthTokens=new Map;secretStore;setSecretStore(t){this.secretStore=t}storeProbeToken(t,e){this.tokens.set(t,e)}storeStaticToken(t,e){this.tokens.set(t,e),this.secretStore?.setJson(Ss,t,{accessToken:e})}storeOAuthToken(t,e){this.oauthTokens.set(t,e),this.tokens.set(t,e.accessToken),this.secretStore?.setJson(Ss,t,{accessToken:e.accessToken,refreshToken:e.refreshToken,expiresAt:e.expiresAt,tokenEndpoint:e.tokenEndpoint,clientId:e.clientId,resource:e.resource})}hydrate(t){if(!this.secretStore)return null;let e=this.secretStore.getJson(Ss,t);return e?.accessToken?(this.tokens.set(t,e.accessToken),e.tokenEndpoint&&e.clientId&&e.resource&&this.oauthTokens.set(t,{accessToken:e.accessToken,refreshToken:e.refreshToken,expiresAt:e.expiresAt,tokenEndpoint:e.tokenEndpoint,clientId:e.clientId,resource:e.resource}),e):null}getToken(t){return this.tokens.get(t)??this.hydrate(t)?.accessToken??void 0}getOAuthToken(t){return this.oauthTokens.has(t)?this.oauthTokens.get(t):(this.hydrate(t),this.oauthTokens.get(t))}hasToken(t){return this.tokens.has(t)||!!this.hydrate(t)}removeToken(t){this.tokens.delete(t),this.oauthTokens.delete(t),this.secretStore?.delete(Ss,t)}getExpiringTokens(t=5*6e4){let e=new Map,s=Date.now();for(let[n,a]of this.oauthTokens)a.refreshToken&&a.expiresAt&&a.expiresAt-s0?t:r.WATCHDOG_FALLBACK_MINUTES)*60*1e3}armWatchdog(){this.clearWatchdog();let t=this.getWatchdogMs();this.watchdogTimer=window.setTimeout(()=>{if(this.watchdogTimer=null,!this.isStreaming)return;this.activeOnEvent?.({type:"error",content:"",errorMessage:`no response from the CLI for ${Math.round(t/6e4)} minutes \u2014 giving up`});let e=new Error("Watchdog timeout");this.handleProcessError(e);try{this.process?.kill()}catch{}},t)}clearWatchdog(){this.watchdogTimer&&(window.clearTimeout(this.watchdogTimer),this.watchdogTimer=null)}currentToolName;activityListeners=new Set;onActivityChange(t){return this.activityListeners.add(t),t(),()=>{this.activityListeners.delete(t)}}emitActivity(){for(let t of this.activityListeners)t()}setStreaming(t){this.isStreaming!==t&&(this.isStreaming=t,t||(this.currentToolName=void 0),this.emitActivity())}setCurrentTool(t){this.currentToolName!==t&&(this.currentToolName=t,this.emitActivity())}stats={costTotalUsd:0,turnCount:0};usageRecorder;lastResultCostUsd=0;setUsageRecorder(t){this.usageRecorder=t}statsListeners=new Set;onStatsChange(t){return this.statsListeners.add(t),t({...this.stats}),()=>{this.statsListeners.delete(t)}}getStats(){return{...this.stats}}emitStats(){let t={...this.stats};for(let e of this.statsListeners)e(t)}mcpAuth;buildMcpProjection(t){let e=this.agent.memory?this.repository.getPendingDirAbsolutePath(this.agent.name):null,s=cn({registry:this.repository.getMcpServers(),agentGrants:this.agent.mcpServers??[],getBearerToken:n=>this.mcpAuth?.getToken(n),remember:e?{pendingDir:e,source:`mcp:${this.captureSource()}`}:null});return this.mcpProjection=dn(t,this.agent.adapter,s),{args:this.mcpProjection?.args??[],env:this.mcpProjection?.env??{},allowServers:s.map(n=>n.def.name)}}memoryWriter;captureSource(){return`chat:${this.inAppConversationId||this.conversationId||"default"}`}getConversationName(){return this.conversationName}async setConversationName(t){this.conversationName=t,await this.persist()}refreshAgent(){let t=this.repository.getAgentByName(this.agent.name);t&&(this.agent=t)}get isThread(){return!!this.threadAnchorId}async loadPersistedState(){let t=this.getChatFilePath(),e=this.vault.getAbstractFileByPath(t);if(!(e instanceof ot.TFile))return!1;try{let s=await this.vault.cachedRead(e);if(this.isThread){let a=JSON.parse(s);return a.messages?.length>0||a.sessionId?(this.messages=(a.messages??[]).map(i=>i.id?i:{...i,id:bi(i)}),this.claudeSessionId=a.sessionId??null,this.threadAnchorIndex=a.anchorIndex,this.claudeSessionId&&(this.basePromptSent=!0),!0):!1}let n=JSON.parse(s);if(n.name&&(this.conversationName=n.name),n.messages?.length>0)return this.messages=n.messages.map(a=>a.id?a:{...a,id:bi(a)}),this.claudeSessionId=n.sessionId??null,this.threadIndex=n.threads??{},this.claudeSessionId&&(this.basePromptSent=!0),!0}catch{}return!1}getThreadIndex(){return{...this.threadIndex}}async persist(){let t=new Date().toISOString(),e=this.getChatFilePath(),s;if(this.isThread){let a={anchorMessageId:this.threadAnchorId,anchorIndex:this.threadAnchorIndex??0,sessionId:this.claudeSessionId,messages:this.messages,createdAt:this.parentSession?.threadIndex[this.threadAnchorId]?.createdAt??t,lastActive:t};s=JSON.stringify(a,null,2)}else{let a={sessionId:this.claudeSessionId,messages:this.messages,lastActive:t,name:this.conversationName||void 0,threads:Object.keys(this.threadIndex).length>0?this.threadIndex:void 0};s=JSON.stringify(a,null,2)}let n=this.vault.getAbstractFileByPath(e);n instanceof ot.TFile?await this.vault.modify(n,s):(await this.ensureParentFolders(e),await this.vault.create(e,s)),this.isThread&&this.parentSession&&this.threadAnchorId&&await this.parentSession.upsertThreadIndex(this.threadAnchorId,{path:e,createdAt:this.parentSession.threadIndex[this.threadAnchorId]?.createdAt??t,messageCount:this.messages.length,lastActive:t})}async upsertThreadIndex(t,e){this.threadIndex[t]=e,await this.persist()}async ensureParentFolders(t){let e=t.lastIndexOf("/");if(e<=0)return;let n=t.slice(0,e).split("/"),a="";for(let i of n)if(a=a?`${a}/${i}`:i,!this.vault.getAbstractFileByPath(a))try{await this.vault.createFolder(a)}catch(o){if(!(o instanceof Error?o.message:String(o)).includes("already exists"))throw o}}async clearPersistedState(){let t=this.getChatFilePath();await this.repository.trashFile(t),this.messages=[],this.claudeSessionId=null,this.basePromptSent=!1}getChatFilePath(){if(this.threadAnchorId&&this.parentSession)return this.parentSession.getThreadFilePath(this.threadAnchorId);if(this.channelName&&this.conversationId){let s=this.settings.fleetFolder,n=oe(this.conversationId)||"conversation";return(0,ot.normalizePath)(`${s}/channels/${this.channelName}/sessions/${n}.json`)}if(!this.inAppConversationId)throw new Error("ChatSession requires inAppConversationId (or channel options) to resolve a file path");let t=oe(this.inAppConversationId)||"conversation";if(this.agent.isFolder){let s=this.agent.filePath.replace(/\/agent\.md$/,"");return(0,ot.normalizePath)(`${s}/conversations/${t}.json`)}let e=this.repository.getMemoryPath(this.agent.name).replace(/\/[^/]+$/,"");return(0,ot.normalizePath)(`${e}/${this.agent.name}-conversations/${t}.json`)}getThreadFilePath(t){let e=this.getParentChatFilePath(),s=e.replace(/\/[^/]+$/,""),n=e.slice(s.length+1).replace(/\.json$/,"");return(0,ot.normalizePath)(`${s}/${n}.threads/${t}.json`)}getParentChatFilePath(){if(this.channelName&&this.conversationId){let t=this.settings.fleetFolder,e=oe(this.conversationId)||"conversation";return(0,ot.normalizePath)(`${t}/channels/${this.channelName}/sessions/${e}.json`)}if(this.inAppConversationId){let t=oe(this.inAppConversationId)||"conversation";if(this.agent.isFolder){let s=this.agent.filePath.replace(/\/agent\.md$/,"");return(0,ot.normalizePath)(`${s}/conversations/${t}.json`)}let e=this.repository.getMemoryPath(this.agent.name).replace(/\/[^/]+$/,"");return(0,ot.normalizePath)(`${e}/${this.agent.name}-conversations/${t}.json`)}throw new Error("ChatSession requires inAppConversationId (or channel options) to resolve a parent file path")}async ensureProcess(){if(this.process&&this.isProcessAlive)return;this.refreshAgent();let t=["--input-format","stream-json","--output-format","stream-json","--verbose"];this.claudeSessionId?(t.push("--resume",this.claudeSessionId),this.basePromptSent=!0,this.claudeResumeAttempted=!0):this.claudeResumeAttempted=!1;let e=It(null,this.agent,this.settings);ws(e.value)&&t.push("--model",e.value);let s=this.agent.permissionMode?.trim();s&&s!=="default"?t.push("--permission-mode",s):t.push("--permission-mode","bypassPermissions"),this.agent.effort&&t.push("--effort",this.agent.effort);let n=this.agent.cwd?.trim()?this.agent.cwd:this.repository.getVaultBasePath()??".",a=this.buildMcpProjection(n);t.push(...a.args),this.settingsState=sn(n,this.agent,{mcpAllowServers:a.allowServers});let i=dt(this.settings.claudeCliPath,t,{cwd:n,env:{...process.env,AWS_REGION:this.settings.awsRegion,...a.env}});this.process=i,this.isProcessAlive=!0,this.stdoutBuffer="",this.lastResultCostUsd=0,this.processListeners={onStdout:o=>this.handleStdout(o),onStderr:()=>{},onError:o=>this.handleProcessError(o),onClose:()=>this.handleProcessClose()},i.stdout.on("data",this.processListeners.onStdout),i.stderr.on("data",this.processListeners.onStderr),i.on("error",this.processListeners.onError),i.on("close",this.processListeners.onClose)}detachProcessListeners(){this.process&&this.processListeners&&(this.process.stdout?.removeListener("data",this.processListeners.onStdout),this.process.stderr?.removeListener("data",this.processListeners.onStderr),this.process.removeListener("error",this.processListeners.onError),this.process.removeListener("close",this.processListeners.onClose)),this.processListeners=null}handleStdout(t){this.isStreaming&&this.armWatchdog(),this.stdoutBuffer+=t.toString();let e=ye(this.stdoutBuffer);this.stdoutBuffer=e.pop()??"";for(let s of e){let n=s.trim();if(n)try{let a=JSON.parse(n);this.handleEvent(a)}catch{}}}handleEvent(t){if(this.isCodex){this.handleCodexEvent(t);return}if(typeof t.session_id=="string"&&(this.claudeSessionId=t.session_id),this.updateStatsFromEvent(t),t.type==="system"&&t.subtype==="compact_boundary"){let s=t.compact_metadata,n=s&&typeof s.pre_tokens=="number"?s.pre_tokens:0,a=s&&typeof s.post_tokens=="number"?s.post_tokens:0;a>0&&(this.stats.contextTokensUsed=a),this.stats.lastCompact={preTokens:n,postTokens:a},this.emitStats(),this.needsCompactBeforeNextTurn=!1,this.activeOnEvent?.({type:"compacted",content:"",compact:{preTokens:n,postTokens:a}});return}if(t.type==="result"){let s=typeof t.api_error_status=="string"&&!!t.api_error_status;if(t.is_error===!0||s){let n=this.describeResultError(t);this.activeOnEvent?.({type:"error",content:"",errorMessage:n}),this.claudeResumeAttempted&&t.is_error===!0&&!s&&this.clearSessionId()}this.handleTurnEnd();return}let e=this.parseStreamEvent(t);e&&this.dispatchStreamEvent(e)}dispatchStreamEvent(t){let e=t;if(t.type==="text"){let s=this.turnResponseText.length===0;if(s&&(this.displayedLen=0),this.turnResponseText+=t.content,this.setCurrentTool(void 0),s&&this.turnResponseText.length>0&&this.emitActivity(),this.agent.memory){let n=ur(this.turnResponseText),a=n.length>this.displayedLen?n.slice(this.displayedLen):"";this.displayedLen=n.length,e={...t,content:a}}}else t.type==="tool_use"&&t.toolName&&(this.turnToolCalls.push({name:t.toolName,command:t.content||void 0}),this.setCurrentTool(t.toolName));this.activeOnEvent?.(e)}handleCodexEvent(t){this.codexTurnState||(this.codexTurnState=ya());let e=ri(t,this.codexTurnState);for(let s of e)switch(s.kind){case"session":this.claudeSessionId=s.sessionId;break;case"text":this.dispatchStreamEvent({type:"text",content:s.text});break;case"tool":this.dispatchStreamEvent({type:"tool_use",content:s.command?s.command.slice(0,150):"",toolName:s.toolName});break;case"usage":{s.contextTokens>0&&s.contextTokens!==this.stats.contextTokensUsed&&(this.stats.contextTokensUsed=s.contextTokens),this.stats.turnCount+=1,this.emitStats();break}case"turn-failed":case"error":this.codexTurnErrors.push(s.message),this.activeOnEvent?.({type:"error",content:"",errorMessage:s.message});break}}get hasCurrentTurnText(){return this.turnResponseText.length>0}describeResultError(t){let e=[],s=typeof t.api_error_status=="string"?t.api_error_status:"",n=typeof t.subtype=="string"?t.subtype:"",a=typeof t.result=="string"?t.result:"";return s?e.push(`API ${s}`):n?e.push(n.replace(/_/g," ")):e.push("unknown error"),a&&e.push(`\u2014 ${a}`),e.join(" ")}updateStatsFromEvent(t){let e=!1,s=typeof t.model=="string"?t.model:void 0,n=t.message,a=n&&typeof n.model=="string"?n.model:void 0,i=s||a;if(i&&i!==this.stats.concreteModel&&(this.stats.concreteModel=i,e=!0),t.type==="rate_limit_event"){let o=t.rate_limit_info;o&&(this.stats.rateLimit={type:typeof o.rateLimitType=="string"?o.rateLimitType:"unknown",resetsAt:typeof o.resetsAt=="number"?o.resetsAt:void 0,status:typeof o.status=="string"?o.status:void 0,isUsingOverage:typeof o.isUsingOverage=="boolean"?o.isUsingOverage:void 0},e=!0)}if(t.type==="assistant"&&n){let o=n.usage;if(o){let c=typeof o.input_tokens=="number"?o.input_tokens:0,l=typeof o.cache_read_input_tokens=="number"?o.cache_read_input_tokens:0,h=typeof o.cache_creation_input_tokens=="number"?o.cache_creation_input_tokens:0,d=c+l+h;d>0&&d!==this.stats.contextTokensUsed&&(this.stats.contextTokensUsed=d,e=!0)}}if(t.type==="result"){let o=typeof t.total_cost_usd=="number"?t.total_cost_usd:0,c=Math.max(0,o-this.lastResultCostUsd);this.lastResultCostUsd=o,c>0&&(this.stats.costTotalUsd+=c,e=!0);let l=t.modelUsage;if(l)for(let h of Object.values(l)){let d=h;typeof d.contextWindow=="number"&&d.contextWindow!==this.stats.contextWindow&&(this.stats.contextWindow=d.contextWindow,e=!0)}this.stats.turnCount+=1,e=!0,this.recordTurnUsage(t,c)}t.type==="result"&&this.evaluateAutoCompact(),e&&this.emitStats()}recordTurnUsage(t,e){if(!this.usageRecorder)return;let s=t.usage,n=h=>typeof h=="number"?h:0,a=n(s?.input_tokens),i=n(s?.output_tokens),o=n(s?.cache_read_input_tokens),c=n(s?.cache_creation_input_tokens),l=a+i+o+c;l===0&&e===0||this.usageRecorder({ts:new Date().toISOString(),agent:this.agent.name,source:this.channelName?"channel":"chat",model:this.stats.concreteModel??this.agent.model,inputTokens:a,outputTokens:i,cacheReadTokens:o,cacheCreateTokens:c,totalTokens:l,...e>0?{costUsd:e}:{}})}evaluateAutoCompact(){if(this.isCodex)return;let t=this.agent.autoCompactThreshold??0;if(t<=0||t>=100)return;let e=this.stats.contextWindow,s=this.stats.contextTokensUsed;!e||!s||s/e*1000&&this.memoryWriter.capture(this.agent,n,this.captureSource(),new Date().toISOString()).catch(a=>console.warn(`Agent Fleet: chat memory capture failed for "${this.agent.name}"`,a)),this.memoryWriter.drainPending(this.agent,new Date().toISOString()).catch(a=>console.warn(`Agent Fleet: chat pending drain failed for "${this.agent.name}"`,a))}e.trim()&&this.messages.push({id:Cs(),role:"assistant",content:e,timestamp:new Date().toISOString(),toolCalls:this.turnToolCalls.length>0?[...this.turnToolCalls]:void 0});let s={text:this.turnResponseText,toolCalls:[...this.turnToolCalls]};if(this.activeOnEvent?.({type:"result",content:"",toolCalls:[...this.turnToolCalls]}),this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns--,this.isCodex){let n=this.codexQueue.shift();if(n!==void 0){this.armWatchdog(),this.startCodexTurn(n).catch(a=>{this.handleProcessError(a instanceof Error?a:new Error(String(a)))});return}this.pendingTurns=0}if(this.pendingTurns<=0){this.pendingTurns=0,this.clearWatchdog(),this.setStreaming(!1),this.persist();let n=this.turnResolve;this.turnResolve=null,this.turnReject=null,n?.(s)}}handleProcessError(t){this.isProcessAlive=!1,this.process=null,this.pendingTurns=0,this.turnResponseText="",this.turnToolCalls=[],this.codexQueue=[],this.clearWatchdog(),this.setStreaming(!1),Dt(this.settingsState),this.settingsState=null,this.codexPermState?.restore(),this.codexPermState=null,xt(this.mcpProjection),this.mcpProjection=null;let e=this.turnReject;this.turnResolve=null,this.turnReject=null,e?.(t)}handleProcessClose(){if(this.isProcessAlive=!1,this.process=null,Dt(this.settingsState),this.settingsState=null,xt(this.mcpProjection),this.mcpProjection=null,this.turnResolve){this.claudeResumeAttempted&&!this.turnResponseText.trim()&&this.clearSessionId();let t={text:this.turnResponseText,toolCalls:[...this.turnToolCalls]};this.turnResponseText.trim()&&this.messages.push({id:Cs(),role:"assistant",content:this.turnResponseText,timestamp:new Date().toISOString(),toolCalls:this.turnToolCalls.length>0?[...this.turnToolCalls]:void 0}),this.pendingTurns=0,this.turnResponseText="",this.turnToolCalls=[],this.clearWatchdog(),this.setStreaming(!1),this.persist();let e=this.turnResolve;this.turnResolve=null,this.turnReject=null,e?.(t)}}async sendMessage(t,e,s,n){this.lastActiveAt=Date.now(),this.messages.push({id:Cs(),role:"user",content:t,timestamp:new Date().toISOString(),attachments:n&&n.length>0?n:void 0});let a=s??t;if(this.basePromptSent||(a=`${await this.buildBasePrompt()} +Document the reliable steps to handle this so future runs don't rediscover them.`};try{await this.repository.writeProposal(o),r.proposed=!0,await this.repository.writeCandidates(e.name,t)}catch(l){console.warn(`Agent Fleet: failed to write proposal for "${e.name}"`,l)}}}async runPendingTask({task:e,promptOverride:t}){let s=()=>{e.tags.includes("heartbeat")&&this.heartbeatsInFlight.delete(e.agent)},n=this.repository.getAgentByName(e.agent);if(!n||!n.enabled){s();return}let r=new Date().toISOString();this.runtimeState.set(n.name,{status:"running",currentTaskId:e.taskId,runStarted:r}),this.resetRunOutput(n.name),this.emitStatusChange();try{let i=await this.executor.execute(n,e,t,g=>this.emitRunOutput(n.name,g)),o=this.consumeAborted(n.name),l=o?[]:this.buildApprovals(n,i.toolsUsed),c=o?"cancelled":this.resolveRunStatus(i,l),d={runId:i.runId,agent:n.name,task:e.taskId,status:c,started:r,completed:new Date().toISOString(),durationSeconds:i.durationSeconds,tokensUsed:i.tokensUsed,costUsd:i.costUsd,model:i.resolvedModel||n.model,modelSource:i.modelSource,concreteModel:i.concreteModel,exitCode:i.exitCode,tags:Array.from(new Set([...n.tags,...e.tags])),prompt:i.prompt,output:i.outputText,toolsUsed:i.toolsUsed.map(g=>`${g.tool}${g.command?`: ${g.command}`:""}`),finalResult:i.finalResult,stderr:i.stderr,approvals:l},u=await this.repository.writeRunLog(d);await this.repository.updateTaskRunMetadata(e,{lastRun:r,runCount:e.runCount+1});let h=0;if(n.memory){let w=e.tags.includes("heartbeat")?"heartbeat":`task:${e.taskId}`,y=Tn(i.outputText);try{await this.memoryWriter.capture(n,y,w,new Date().toISOString()),h=y.length}catch(v){console.warn(`Agent Fleet: failed to append memory for "${n.name}"`,v)}}let p=e.tags.includes("heartbeat"),m=p?n.heartbeatChannel:e.channel??"",f=p?n.heartbeatChannelTarget??"":e.channelTarget??"";if(m&&!o&&d.output.trim())try{this.channelResultHandler?.(n.name,m,d.output,p?"heartbeat":e.taskId,f)}catch(g){console.warn(`Agent Fleet: channel delivery failed for ${n.name}`,g)}o&&(d.output="Task was manually stopped."),await this.refreshRunCaches(),this.runtimeState.set(n.name,{status:o||c==="success"?"idle":c==="pending_approval"?"pending":"error",currentRunId:i.runId,lastRun:{...d,filePath:u}}),o||this.notify(d,h)}catch(i){let o=this.consumeAborted(n.name),l=o?"cancelled":"failure",c=ts(e,n,this.settings),d={runId:(0,mr.randomUUID)(),agent:n.name,task:e.taskId,status:l,started:r,completed:new Date().toISOString(),durationSeconds:Math.round((Date.now()-new Date(r).getTime())/1e3),model:c.value||n.model,modelSource:c.source,exitCode:o?-1:1,tags:Array.from(new Set([...n.tags,...e.tags])),prompt:t??e.body,output:o?"Task was manually stopped.":i instanceof Error?i.message:String(i),toolsUsed:[]},u=await this.repository.writeRunLog(d);await this.refreshRunCaches(),this.runtimeState.set(n.name,{status:o?"idle":"error",lastRun:{...d,filePath:u}}),o||this.notify(d)}finally{if(s(),n.memory)try{await this.memoryWriter.drainPending(n,new Date().toISOString())}catch(i){console.warn(`Agent Fleet: failed to drain pending memory for "${n.name}"`,i)}this.clearRunOutput(n.name),this.emitStatusChange()}}buildApprovals(e,t){let s=t.filter(n=>e.approvalRequired.includes(n.tool)).map(n=>({tool:n.tool,command:n.command,reason:n.reason,status:"pending"}));return s.length>0?s:void 0}resolveRunStatus(e,t){return t?.length?"pending_approval":e.timedOut?"timeout":e.exitCode===0?"success":"failure"}notify(e,t=0){if(this.settings.notificationLevel==="none"||this.settings.notificationLevel==="failures-only"&&e.status==="success")return;let n=(re(e.output).map(o=>o.trim()).find(o=>o&&!o.startsWith("{")&&!o.startsWith("["))??"").slice(0,120)||e.status,r=t>0?` \xB7 captured ${t} memory fact${t===1?"":"s"}`:"",i=e.status==="success"?`\u2705 ${e.agent}: ${n}${r}`:e.status==="pending_approval"?`\u{1F535} ${e.agent} needs approval: ${(e.approvals??[])[0]?.tool??"tool action"}`:`\u274C ${e.agent}: ${n}`;new xo.Notice(i,e.status==="success"?5e3:0)}emitStatusChange(){for(let e of this.statusChangeListeners)e()}};function zd(a){return a.toLowerCase().replace(/[^a-z0-9-]/g,"-").replace(/-{2,}/g,"-").replace(/^-|-$/g,"")}function Ks(a,e){return`${a}-${zd(e)}`}var gs="af-channel-cred",Js="af-mcp-secret",Zn=class{constructor(e){this.storage=e;e||console.warn("Agent Fleet: SecretStorage unavailable (Obsidian < 1.11.4). Secrets will use plaintext fallback.")}get available(){return!!this.storage}setJson(e,t,s){if(!this.storage)return;let n=Ks(e,t);this.storage.setSecret(n,JSON.stringify(s))}getJson(e,t){if(!this.storage)return null;let s=Ks(e,t),n=this.storage.getSecret(s);if(!n)return null;try{return JSON.parse(n)}catch{return null}}setString(e,t,s){if(!this.storage)return;let n=Ks(e,t);this.storage.setSecret(n,s)}getString(e,t){if(!this.storage)return null;let s=Ks(e,t);return this.storage.getSecret(s)||null}delete(e,t){if(!this.storage)return;let s=Ks(e,t);this.storage.setSecret(s,"")}listByPrefix(e){return this.storage?this.storage.listSecrets().filter(t=>t.startsWith(e+"-")):[]}};var ea=class{tokens=new Map;oauthTokens=new Map;secretStore;setSecretStore(e){this.secretStore=e}storeProbeToken(e,t){this.tokens.set(e,t)}storeStaticToken(e,t){this.tokens.set(e,t),this.secretStore?.setJson(Js,e,{accessToken:t})}storeOAuthToken(e,t){this.oauthTokens.set(e,t),this.tokens.set(e,t.accessToken),this.secretStore?.setJson(Js,e,{accessToken:t.accessToken,refreshToken:t.refreshToken,expiresAt:t.expiresAt,tokenEndpoint:t.tokenEndpoint,clientId:t.clientId,resource:t.resource})}hydrate(e){if(!this.secretStore)return null;let t=this.secretStore.getJson(Js,e);return t?.accessToken?(this.tokens.set(e,t.accessToken),t.tokenEndpoint&&t.clientId&&t.resource&&this.oauthTokens.set(e,{accessToken:t.accessToken,refreshToken:t.refreshToken,expiresAt:t.expiresAt,tokenEndpoint:t.tokenEndpoint,clientId:t.clientId,resource:t.resource}),t):null}getToken(e){return this.tokens.get(e)??this.hydrate(e)?.accessToken??void 0}getOAuthToken(e){return this.oauthTokens.has(e)?this.oauthTokens.get(e):(this.hydrate(e),this.oauthTokens.get(e))}hasToken(e){return this.tokens.has(e)||!!this.hydrate(e)}removeToken(e){this.tokens.delete(e),this.oauthTokens.delete(e),this.secretStore?.delete(Js,e)}getExpiringTokens(e=5*6e4){let t=new Map,s=Date.now();for(let[n,r]of this.oauthTokens)r.refreshToken&&r.expiresAt&&r.expiresAt-s0?e:a.WATCHDOG_FALLBACK_MINUTES)*60*1e3}armWatchdog(){this.clearWatchdog();let e=this.getWatchdogMs();this.watchdogTimer=window.setTimeout(()=>{if(this.watchdogTimer=null,!this.isStreaming)return;this.activeOnEvent?.({type:"error",content:"",errorMessage:`no response from the CLI for ${Math.round(e/6e4)} minutes \u2014 giving up`});let t=new Error("Watchdog timeout");this.handleProcessError(t);try{this.process?.kill()}catch{}},e)}clearWatchdog(){this.watchdogTimer&&(window.clearTimeout(this.watchdogTimer),this.watchdogTimer=null)}currentToolName;activityListeners=new Set;onActivityChange(e){return this.activityListeners.add(e),e(),()=>{this.activityListeners.delete(e)}}emitActivity(){for(let e of this.activityListeners)e()}setStreaming(e){this.isStreaming!==e&&(this.isStreaming=e,e||(this.currentToolName=void 0),this.emitActivity())}setCurrentTool(e){this.currentToolName!==e&&(this.currentToolName=e,this.emitActivity())}stats={costTotalUsd:0,turnCount:0};usageRecorder;lastResultCostUsd=0;setUsageRecorder(e){this.usageRecorder=e}statsListeners=new Set;onStatsChange(e){return this.statsListeners.add(e),e({...this.stats}),()=>{this.statsListeners.delete(e)}}getStats(){return{...this.stats}}emitStats(){let e={...this.stats};for(let t of this.statsListeners)t(e)}mcpAuth;buildMcpProjection(e){let t=this.agent.memory?this.repository.getPendingDirAbsolutePath(this.agent.name):null,s=Vn({registry:this.repository.getMcpServers(),agentGrants:this.agent.mcpServers??[],getBearerToken:n=>this.mcpAuth?.getToken(n),remember:t?{pendingDir:t,source:`mcp:${this.captureSource()}`}:null});return this.mcpProjection=Yn(e,this.agent.adapter,s),{args:this.mcpProjection?.args??[],env:this.mcpProjection?.env??{},allowServers:s.map(n=>n.def.name)}}memoryWriter;captureSource(){return`chat:${this.inAppConversationId||this.conversationId||"default"}`}getConversationName(){return this.conversationName}async setConversationName(e){this.conversationName=e,await this.persist()}refreshAgent(){let e=this.repository.getAgentByName(this.agent.name);e&&(this.agent=e)}get isThread(){return!!this.threadAnchorId}async loadPersistedState(){let e=this.getChatFilePath(),t=this.vault.getAbstractFileByPath(e);if(!(t instanceof gt.TFile))return!1;try{let s=await this.vault.cachedRead(t);if(this.isThread){let r=JSON.parse(s);return r.messages?.length>0||r.sessionId?(this.messages=(r.messages??[]).map(i=>i.id?i:{...i,id:So(i)}),this.claudeSessionId=r.sessionId??null,this.threadAnchorIndex=r.anchorIndex,this.claudeSessionId&&(this.basePromptSent=!0),!0):!1}let n=JSON.parse(s);if(n.name&&(this.conversationName=n.name),n.messages?.length>0)return this.messages=n.messages.map(r=>r.id?r:{...r,id:So(r)}),this.claudeSessionId=n.sessionId??null,this.threadIndex=n.threads??{},this.claudeSessionId&&(this.basePromptSent=!0),!0}catch{}return!1}getThreadIndex(){return{...this.threadIndex}}async persist(){let e=new Date().toISOString(),t=this.getChatFilePath(),s;if(this.isThread){let r={anchorMessageId:this.threadAnchorId,anchorIndex:this.threadAnchorIndex??0,sessionId:this.claudeSessionId,messages:this.messages,createdAt:this.parentSession?.threadIndex[this.threadAnchorId]?.createdAt??e,lastActive:e};s=JSON.stringify(r,null,2)}else{let r={sessionId:this.claudeSessionId,messages:this.messages,lastActive:e,name:this.conversationName||void 0,threads:Object.keys(this.threadIndex).length>0?this.threadIndex:void 0};s=JSON.stringify(r,null,2)}let n=this.vault.getAbstractFileByPath(t);n instanceof gt.TFile?await this.vault.modify(n,s):(await this.ensureParentFolders(t),await this.vault.create(t,s)),this.isThread&&this.parentSession&&this.threadAnchorId&&await this.parentSession.upsertThreadIndex(this.threadAnchorId,{path:t,createdAt:this.parentSession.threadIndex[this.threadAnchorId]?.createdAt??e,messageCount:this.messages.length,lastActive:e})}async upsertThreadIndex(e,t){this.threadIndex[e]=t,await this.persist()}async ensureParentFolders(e){let t=e.lastIndexOf("/");if(t<=0)return;let n=e.slice(0,t).split("/"),r="";for(let i of n)if(r=r?`${r}/${i}`:i,!this.vault.getAbstractFileByPath(r))try{await this.vault.createFolder(r)}catch(o){if(!(o instanceof Error?o.message:String(o)).includes("already exists"))throw o}}async clearPersistedState(){let e=this.getChatFilePath();await this.repository.trashFile(e),this.messages=[],this.claudeSessionId=null,this.basePromptSent=!1}getChatFilePath(){if(this.threadAnchorId&&this.parentSession)return this.parentSession.getThreadFilePath(this.threadAnchorId);if(this.channelName&&this.conversationId){let s=this.settings.fleetFolder,n=z(this.conversationId)||"conversation";return(0,gt.normalizePath)(`${s}/channels/${this.channelName}/sessions/${n}.json`)}if(!this.inAppConversationId)throw new Error("ChatSession requires inAppConversationId (or channel options) to resolve a file path");let e=z(this.inAppConversationId)||"conversation";if(this.agent.isFolder){let s=this.agent.filePath.replace(/\/agent\.md$/,"");return(0,gt.normalizePath)(`${s}/conversations/${e}.json`)}let t=this.repository.getMemoryPath(this.agent.name).replace(/\/[^/]+$/,"");return(0,gt.normalizePath)(`${t}/${this.agent.name}-conversations/${e}.json`)}getThreadFilePath(e){let t=this.getParentChatFilePath(),s=t.replace(/\/[^/]+$/,""),n=t.slice(s.length+1).replace(/\.json$/,"");return(0,gt.normalizePath)(`${s}/${n}.threads/${e}.json`)}getParentChatFilePath(){if(this.channelName&&this.conversationId){let e=this.settings.fleetFolder,t=z(this.conversationId)||"conversation";return(0,gt.normalizePath)(`${e}/channels/${this.channelName}/sessions/${t}.json`)}if(this.inAppConversationId){let e=z(this.inAppConversationId)||"conversation";if(this.agent.isFolder){let s=this.agent.filePath.replace(/\/agent\.md$/,"");return(0,gt.normalizePath)(`${s}/conversations/${e}.json`)}let t=this.repository.getMemoryPath(this.agent.name).replace(/\/[^/]+$/,"");return(0,gt.normalizePath)(`${t}/${this.agent.name}-conversations/${e}.json`)}throw new Error("ChatSession requires inAppConversationId (or channel options) to resolve a parent file path")}async ensureProcess(){if(this.process&&this.isProcessAlive)return;this.refreshAgent();let e=["--input-format","stream-json","--output-format","stream-json","--verbose"];this.claudeSessionId?(e.push("--resume",this.claudeSessionId),this.basePromptSent=!0,this.claudeResumeAttempted=!0):this.claudeResumeAttempted=!1;let t=ts(null,this.agent,this.settings);Gs(t.value)&&e.push("--model",t.value);let s=this.agent.permissionMode?.trim();s&&s!=="default"?e.push("--permission-mode",s):e.push("--permission-mode","bypassPermissions"),this.agent.effort&&e.push("--effort",this.agent.effort);let n=this.agent.cwd?.trim()?this.agent.cwd:this.repository.getVaultBasePath()??".",r=this.buildMcpProjection(n);e.push(...r.args),this.settingsState=jn(n,this.agent,{mcpAllowServers:r.allowServers});let i=wt(this.settings.claudeCliPath,e,{cwd:n,env:{...process.env,AWS_REGION:this.settings.awsRegion,...r.env}});this.process=i,this.isProcessAlive=!0,this.stdoutBuffer="",this.lastResultCostUsd=0,this.processListeners={onStdout:o=>this.handleStdout(o),onStderr:()=>{},onError:o=>this.handleProcessError(o),onClose:()=>this.handleProcessClose()},i.stdout.on("data",this.processListeners.onStdout),i.stderr.on("data",this.processListeners.onStderr),i.on("error",this.processListeners.onError),i.on("close",this.processListeners.onClose)}detachProcessListeners(){this.process&&this.processListeners&&(this.process.stdout?.removeListener("data",this.processListeners.onStdout),this.process.stderr?.removeListener("data",this.processListeners.onStderr),this.process.removeListener("error",this.processListeners.onError),this.process.removeListener("close",this.processListeners.onClose)),this.processListeners=null}handleStdout(e){this.isStreaming&&this.armWatchdog(),this.stdoutBuffer+=e.toString();let t=re(this.stdoutBuffer);this.stdoutBuffer=t.pop()??"",this.stdoutBuffer.length>a.STDOUT_LINE_CAP&&(console.warn(`Agent Fleet: chat stdout line exceeded ${a.STDOUT_LINE_CAP} chars \u2014 dropping partial line`),this.stdoutBuffer="");for(let s of t){let n=s.trim();if(n)try{let r=JSON.parse(n);this.handleEvent(r)}catch{}}}handleEvent(e){if(this.isCodex){this.handleCodexEvent(e);return}if(typeof e.session_id=="string"&&(this.claudeSessionId=e.session_id),this.updateStatsFromEvent(e),e.type==="system"&&e.subtype==="compact_boundary"){let s=e.compact_metadata,n=s&&typeof s.pre_tokens=="number"?s.pre_tokens:0,r=s&&typeof s.post_tokens=="number"?s.post_tokens:0;r>0&&(this.stats.contextTokensUsed=r),this.stats.lastCompact={preTokens:n,postTokens:r},this.emitStats(),this.needsCompactBeforeNextTurn=!1,this.activeOnEvent?.({type:"compacted",content:"",compact:{preTokens:n,postTokens:r}});return}if(e.type==="result"){let s=typeof e.api_error_status=="string"&&!!e.api_error_status;if(e.is_error===!0||s){let n=this.describeResultError(e);this.activeOnEvent?.({type:"error",content:"",errorMessage:n}),this.claudeResumeAttempted&&e.is_error===!0&&!s&&this.clearSessionId()}this.handleTurnEnd();return}let t=this.parseStreamEvent(e);t&&this.dispatchStreamEvent(t)}dispatchStreamEvent(e){let t=e;if(e.type==="text"){let s=this.turnResponseText.length===0;if(s&&(this.displayedLen=0),this.turnResponseText+=e.content,this.setCurrentTool(void 0),s&&this.turnResponseText.length>0&&this.emitActivity(),this.agent.memory){let n=hi(this.turnResponseText),r=n.length>this.displayedLen?n.slice(this.displayedLen):"";this.displayedLen=n.length,t={...e,content:r}}}else e.type==="tool_use"&&e.toolName&&(this.turnToolCalls.push({name:e.toolName,command:e.content||void 0}),this.setCurrentTool(e.toolName));this.activeOnEvent?.(t)}handleCodexEvent(e){this.codexTurnState||(this.codexTurnState=lr());let t=no(e,this.codexTurnState);for(let s of t)switch(s.kind){case"session":this.claudeSessionId=s.sessionId;break;case"text":this.dispatchStreamEvent({type:"text",content:s.text});break;case"tool":this.dispatchStreamEvent({type:"tool_use",content:s.command?s.command.slice(0,150):"",toolName:s.toolName});break;case"usage":{s.contextTokens>0&&s.contextTokens!==this.stats.contextTokensUsed&&(this.stats.contextTokensUsed=s.contextTokens),this.stats.turnCount+=1,this.emitStats();break}case"turn-failed":case"error":this.codexTurnErrors.push(s.message),this.activeOnEvent?.({type:"error",content:"",errorMessage:s.message});break}}get hasCurrentTurnText(){return this.turnResponseText.length>0}describeResultError(e){let t=[],s=typeof e.api_error_status=="string"?e.api_error_status:"",n=typeof e.subtype=="string"?e.subtype:"",r=typeof e.result=="string"?e.result:"";return s?t.push(`API ${s}`):n?t.push(n.replace(/_/g," ")):t.push("unknown error"),r&&t.push(`\u2014 ${r}`),t.join(" ")}updateStatsFromEvent(e){let t=!1,s=typeof e.model=="string"?e.model:void 0,n=e.message,r=n&&typeof n.model=="string"?n.model:void 0,i=s||r;if(i&&i!==this.stats.concreteModel&&(this.stats.concreteModel=i,t=!0),e.type==="rate_limit_event"){let o=e.rate_limit_info;o&&(this.stats.rateLimit={type:typeof o.rateLimitType=="string"?o.rateLimitType:"unknown",resetsAt:typeof o.resetsAt=="number"?o.resetsAt:void 0,status:typeof o.status=="string"?o.status:void 0,isUsingOverage:typeof o.isUsingOverage=="boolean"?o.isUsingOverage:void 0},t=!0)}if(e.type==="assistant"&&n){let o=n.usage;if(o){let l=typeof o.input_tokens=="number"?o.input_tokens:0,c=typeof o.cache_read_input_tokens=="number"?o.cache_read_input_tokens:0,d=typeof o.cache_creation_input_tokens=="number"?o.cache_creation_input_tokens:0,u=l+c+d;u>0&&u!==this.stats.contextTokensUsed&&(this.stats.contextTokensUsed=u,t=!0)}}if(e.type==="result"){let o=typeof e.total_cost_usd=="number"?e.total_cost_usd:0,l=Math.max(0,o-this.lastResultCostUsd);this.lastResultCostUsd=o,l>0&&(this.stats.costTotalUsd+=l,t=!0);let c=e.modelUsage;if(c)for(let d of Object.values(c)){let u=d;typeof u.contextWindow=="number"&&u.contextWindow!==this.stats.contextWindow&&(this.stats.contextWindow=u.contextWindow,t=!0)}this.stats.turnCount+=1,t=!0,this.recordTurnUsage(e,l)}e.type==="result"&&this.evaluateAutoCompact(),t&&this.emitStats()}recordTurnUsage(e,t){if(!this.usageRecorder)return;let s=e.usage,n=d=>typeof d=="number"?d:0,r=n(s?.input_tokens),i=n(s?.output_tokens),o=n(s?.cache_read_input_tokens),l=n(s?.cache_creation_input_tokens),c=r+i+o+l;c===0&&t===0||this.usageRecorder({ts:new Date().toISOString(),agent:this.agent.name,source:this.channelName?"channel":"chat",model:this.stats.concreteModel??this.agent.model,inputTokens:r,outputTokens:i,cacheReadTokens:o,cacheCreateTokens:l,totalTokens:c,...t>0?{costUsd:t}:{}})}evaluateAutoCompact(){if(this.isCodex)return;let e=this.agent.autoCompactThreshold??0;if(e<=0||e>=100)return;let t=this.stats.contextWindow,s=this.stats.contextTokensUsed;!t||!s||s/t*1000&&this.memoryWriter.capture(this.agent,n,this.captureSource(),new Date().toISOString()).catch(r=>console.warn(`Agent Fleet: chat memory capture failed for "${this.agent.name}"`,r)),this.memoryWriter.drainPending(this.agent,new Date().toISOString()).catch(r=>console.warn(`Agent Fleet: chat pending drain failed for "${this.agent.name}"`,r))}t.trim()&&this.messages.push({id:Xs(),role:"assistant",content:t,timestamp:new Date().toISOString(),toolCalls:this.turnToolCalls.length>0?[...this.turnToolCalls]:void 0});let s={text:this.turnResponseText,toolCalls:[...this.turnToolCalls]};if(this.activeOnEvent?.({type:"result",content:"",toolCalls:[...this.turnToolCalls]}),this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns--,this.isCodex){let n=this.codexQueue.shift();if(n!==void 0){this.armWatchdog(),this.startCodexTurn(n).catch(r=>{let i=1+this.codexQueue.length;this.activeOnEvent?.({type:"error",content:"",errorMessage:`failed to start queued Codex turn \u2014 dropping ${i} queued message${i===1?"":"s"}: ${r instanceof Error?r.message:String(r)}`}),this.handleProcessError(r instanceof Error?r:new Error(String(r)))});return}this.pendingTurns=0}if(this.pendingTurns<=0){this.pendingTurns=0,this.clearWatchdog(),this.setStreaming(!1),this.persist();let n=this.turnResolve;this.turnResolve=null,this.turnReject=null,n?.(s)}}handleProcessError(e){this.isProcessAlive=!1,this.process=null,this.pendingTurns=0,this.turnResponseText="",this.turnToolCalls=[],this.codexQueue=[],this.clearWatchdog(),this.setStreaming(!1),Zt(this.settingsState),this.settingsState=null,this.codexPermState?.restore(),this.codexPermState=null,Ut(this.mcpProjection),this.mcpProjection=null;let t=this.turnReject;this.turnResolve=null,this.turnReject=null,t?.(e)}handleProcessClose(){this.isProcessAlive=!1,this.process=null,Zt(this.settingsState),this.settingsState=null,Ut(this.mcpProjection),this.mcpProjection=null;let e=this.turnResolve,t=null;e&&(this.claudeResumeAttempted&&!this.turnResponseText.trim()&&this.clearSessionId(),t={text:this.turnResponseText,toolCalls:[...this.turnToolCalls]},this.turnResponseText.trim()&&this.messages.push({id:Xs(),role:"assistant",content:this.turnResponseText,timestamp:new Date().toISOString(),toolCalls:this.turnToolCalls.length>0?[...this.turnToolCalls]:void 0})),this.pendingTurns=0,this.turnResponseText="",this.turnToolCalls=[],this.clearWatchdog(),this.setStreaming(!1),e&&t&&(this.persist(),this.turnResolve=null,this.turnReject=null,e(t))}async sendMessage(e,t,s,n){this.lastActiveAt=Date.now(),this.messages.push({id:Xs(),role:"user",content:e,timestamp:new Date().toISOString(),attachments:n&&n.length>0?n:void 0});let r=s??e;if(this.basePromptSent||(r=`${await this.buildBasePrompt()} ## Task -${a}`,this.basePromptSent=!0),this.isCodex){this.stats.lastCompact&&(this.stats.lastCompact=void 0,this.emitStats()),this.activeOnEvent=e,this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns=1,this.setStreaming(!0),this.armWatchdog();try{await this.startCodexTurn(a)}catch(c){throw this.pendingTurns=0,this.clearWatchdog(),this.setStreaming(!1),new Error(`Failed to start Codex process: ${c instanceof Error?c.message:String(c)}`)}return new Promise((c,l)=>{this.turnResolve=c,this.turnReject=l})}await this.ensureProcess();let i=this.needsCompactBeforeNextTurn;i&&(this.needsCompactBeforeNextTurn=!1,this.lastCompactTriggerAt=Date.now()),this.stats.lastCompact&&(this.stats.lastCompact=void 0,this.emitStats()),this.activeOnEvent=e,this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns=i?2:1,this.setStreaming(!0),this.armWatchdog();let o=c=>{let l=JSON.stringify({type:"user",message:{role:"user",content:c}});this.process.stdin.write(l+` -`)};try{i&&o("/compact"),o(a)}catch(c){throw this.pendingTurns=0,this.setStreaming(!1),new Error(`Failed to write to Claude process stdin: ${c instanceof Error?c.message:String(c)}`)}return new Promise((c,l)=>{this.turnResolve=c,this.turnReject=l})}scheduleCompact(){this.isCodex||(this.needsCompactBeforeNextTurn=!0)}async startCodexTurn(t){this.refreshAgent();let e=It(null,this.agent,this.settings),s=await vs.buildExec({prompt:t,model:ws(e.value)?e.value:"",modelSource:e.source,effort:this.agent.effort??"",agent:this.agent,settings:this.settings,streaming:!0,resumeSessionId:this.claudeSessionId});e.value&&this.stats.concreteModel!==e.value&&(this.stats.concreteModel=e.value,this.emitStats()),this.codexTurnState=ya(),this.codexResumeAttempted=!!this.claudeSessionId,this.codexTurnErrors=[],this.codexStderr="";let n=this.agent.cwd?.trim()?this.agent.cwd:this.repository.getVaultBasePath()??".",a=this.buildMcpProjection(n);s.args.push(...a.args),this.codexPermState=await vs.setupPermissions(n,this.agent,this.settings);let i=dt(s.cliPath,s.args,{cwd:n,env:{...process.env,AWS_REGION:this.settings.awsRegion,...a.env,...this.codexPermState?.env??{}}});this.process=i,this.isProcessAlive=!0,this.stdoutBuffer="",this.processListeners={onStdout:o=>this.handleStdout(o),onStderr:o=>{this.codexStderr+=o.toString()},onError:o=>this.handleProcessError(o),onClose:o=>this.handleCodexProcessClose(o)},i.stdout.on("data",this.processListeners.onStdout),i.stderr.on("data",this.processListeners.onStderr),i.on("error",this.processListeners.onError),i.on("close",this.processListeners.onClose);try{i.stdin.write(s.stdinPayload??t),i.stdin.end()}catch(o){try{i.kill()}catch{}throw o instanceof Error?o:new Error(String(o))}}handleCodexProcessClose(t){if(this.detachProcessListeners(),this.isProcessAlive=!1,this.process=null,this.stdoutBuffer="",this.codexPermState?.restore(),this.codexPermState=null,xt(this.mcpProjection),this.mcpProjection=null,t!==0&&!this.turnResponseText.trim()){let s=this.codexTurnErrors.join("; ")||this.codexStderr.trim().slice(-500)||`Codex CLI exited with code ${t??"unknown"}`;this.codexResumeAttempted&&this.clearSessionId(),this.codexQueue=[],this.activeOnEvent?.({type:"error",content:"",errorMessage:s})}this.handleTurnEnd()}injectMessage(t,e,s){if(this.isCodex){if(!this.isStreaming&&this.pendingTurns===0)return;this.messages.push({id:Cs(),role:"user",content:t,timestamp:new Date().toISOString(),attachments:s&&s.length>0?s:void 0}),this.codexQueue.push(e??t),this.pendingTurns++;return}if(!this.process||!this.isProcessAlive)return;this.messages.push({id:Cs(),role:"user",content:t,timestamp:new Date().toISOString(),attachments:s&&s.length>0?s:void 0});let a=JSON.stringify({type:"user",message:{role:"user",content:e??t}});try{this.process.stdin.write(a+` -`)}catch(i){console.warn("Agent Fleet: injectMessage stdin write failed",i);return}this.pendingTurns++}abort(){this.detachProcessListeners(),this.process&&(this.process.kill(),this.process=null),this.isProcessAlive=!1,this.stdoutBuffer="",this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns=0,this.codexQueue=[],this.clearWatchdog(),this.setStreaming(!1),Dt(this.settingsState),this.settingsState=null,this.codexPermState?.restore(),this.codexPermState=null,xt(this.mcpProjection),this.mcpProjection=null;let t=this.turnReject;this.turnResolve=null,this.turnReject=null,t?.(new Error("Aborted"))}dispose(){for(let t of this.threads.values())t.abort();this.threads.clear(),this.threadIndex={},this.abort()}hibernate(){this.isStreaming||this.pendingTurns>0||(this.detachProcessListeners(),this.process&&(this.process.kill(),this.process=null),this.isProcessAlive=!1,this.stdoutBuffer="",Dt(this.settingsState),this.settingsState=null,xt(this.mcpProjection),this.mcpProjection=null)}clearSessionId(){this.claudeSessionId=null,this.basePromptSent=!1,this.claudeResumeAttempted=!1}async buildBasePrompt(){let t=[this.agent.body.trim()];for(let s of this.agent.skills){let n=this.repository.getSkillByName(s);if(n){let a=[n.body.trim()];n.toolsBody.trim()&&a.push(`### Tools -${n.toolsBody.trim()}`),n.referencesBody.trim()&&a.push(`### References -${n.referencesBody.trim()}`),n.examplesBody.trim()&&a.push(`### Examples -${n.examplesBody.trim()}`),t.push(`## Skill: ${n.name} -${a.join(` +${r}`,this.basePromptSent=!0),this.isCodex){this.stats.lastCompact&&(this.stats.lastCompact=void 0,this.emitStats()),this.activeOnEvent=t,this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns=1,this.setStreaming(!0),this.armWatchdog();try{await this.startCodexTurn(r)}catch(l){throw this.pendingTurns=0,this.clearWatchdog(),this.setStreaming(!1),new Error(`Failed to start Codex process: ${l instanceof Error?l.message:String(l)}`)}return new Promise((l,c)=>{this.turnResolve=l,this.turnReject=c})}await this.ensureProcess();let i=this.needsCompactBeforeNextTurn;i&&(this.needsCompactBeforeNextTurn=!1,this.lastCompactTriggerAt=Date.now()),this.stats.lastCompact&&(this.stats.lastCompact=void 0,this.emitStats()),this.activeOnEvent=t,this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns=i?2:1,this.setStreaming(!0),this.armWatchdog();let o=l=>{let c=JSON.stringify({type:"user",message:{role:"user",content:l}});this.process.stdin.write(c+` +`)};try{i&&o("/compact"),o(r)}catch(l){throw this.pendingTurns=0,this.setStreaming(!1),new Error(`Failed to write to Claude process stdin: ${l instanceof Error?l.message:String(l)}`)}return new Promise((l,c)=>{this.turnResolve=l,this.turnReject=c})}scheduleCompact(){this.isCodex||(this.needsCompactBeforeNextTurn=!0)}async startCodexTurn(e){this.refreshAgent();let t=ts(null,this.agent,this.settings),s=await zs.buildExec({prompt:e,model:Gs(t.value)?t.value:"",modelSource:t.source,effort:this.agent.effort??"",agent:this.agent,settings:this.settings,streaming:!0,resumeSessionId:this.claudeSessionId});t.value&&this.stats.concreteModel!==t.value&&(this.stats.concreteModel=t.value,this.emitStats()),this.codexTurnState=lr(),this.codexResumeAttempted=!!this.claudeSessionId,this.codexTurnErrors=[],this.codexStderr="";let n=this.agent.cwd?.trim()?this.agent.cwd:this.repository.getVaultBasePath()??".",r=this.buildMcpProjection(n);s.args.push(...r.args),this.codexPermState=await zs.setupPermissions(n,this.agent,this.settings);let i=wt(s.cliPath,s.args,{cwd:n,env:{...process.env,AWS_REGION:this.settings.awsRegion,...r.env,...this.codexPermState?.env??{}}});this.process=i,this.isProcessAlive=!0,this.stdoutBuffer="",this.processListeners={onStdout:o=>this.handleStdout(o),onStderr:o=>{this.codexStderr+=o.toString()},onError:o=>this.handleProcessError(o),onClose:o=>this.handleCodexProcessClose(o)},i.stdout.on("data",this.processListeners.onStdout),i.stderr.on("data",this.processListeners.onStderr),i.on("error",this.processListeners.onError),i.on("close",this.processListeners.onClose);try{i.stdin.write(s.stdinPayload??e),i.stdin.end()}catch(o){this.detachProcessListeners();try{i.kill()}catch{}throw this.process=null,this.isProcessAlive=!1,o instanceof Error?o:new Error(String(o))}}handleCodexProcessClose(e){if(this.detachProcessListeners(),this.isProcessAlive=!1,this.process=null,this.stdoutBuffer="",this.codexPermState?.restore(),this.codexPermState=null,Ut(this.mcpProjection),this.mcpProjection=null,e!==0&&!this.turnResponseText.trim()){let s=this.codexTurnErrors.join("; ")||this.codexStderr.trim().slice(-500)||`Codex CLI exited with code ${e??"unknown"}`;this.codexResumeAttempted&&this.clearSessionId(),this.codexQueue=[],this.activeOnEvent?.({type:"error",content:"",errorMessage:s})}this.handleTurnEnd()}injectMessage(e,t,s){if(this.isCodex){if(!this.isStreaming&&this.pendingTurns===0)return;this.messages.push({id:Xs(),role:"user",content:e,timestamp:new Date().toISOString(),attachments:s&&s.length>0?s:void 0}),this.codexQueue.push(t??e),this.pendingTurns++;return}if(!this.process||!this.isProcessAlive)return;this.messages.push({id:Xs(),role:"user",content:e,timestamp:new Date().toISOString(),attachments:s&&s.length>0?s:void 0});let r=JSON.stringify({type:"user",message:{role:"user",content:t??e}});try{this.process.stdin.write(r+` +`)}catch(i){console.warn("Agent Fleet: injectMessage stdin write failed",i);return}this.pendingTurns++}abort(){this.detachProcessListeners(),this.process&&(this.process.kill(),this.process=null),this.isProcessAlive=!1,this.stdoutBuffer="",this.turnResponseText="",this.turnToolCalls=[],this.pendingTurns=0,this.codexQueue=[],this.clearWatchdog(),this.setStreaming(!1),Zt(this.settingsState),this.settingsState=null,this.codexPermState?.restore(),this.codexPermState=null,Ut(this.mcpProjection),this.mcpProjection=null;let e=this.turnReject;this.turnResolve=null,this.turnReject=null,e?.(new Error("Aborted"))}dispose(){for(let e of this.threads.values())e.abort();this.threads.clear(),this.threadIndex={},this.abort()}hibernate(){this.isStreaming||this.pendingTurns>0||(this.detachProcessListeners(),this.process&&(this.process.kill(),this.process=null),this.isProcessAlive=!1,this.stdoutBuffer="",Zt(this.settingsState),this.settingsState=null,Ut(this.mcpProjection),this.mcpProjection=null)}clearSessionId(){this.claudeSessionId=null,this.basePromptSent=!1,this.claudeResumeAttempted=!1}async buildBasePrompt(){let e=await qn(this.repository,this.agent,{memoryActive:this.agent.memory,channelContext:this.channelContext});if(this.isThread&&this.parentSession&&this.threadAnchorIndex!==void 0){let t="You are continuing a side thread from this conversation. The user is following up on one of your earlier replies and wants to explore something specific without adding to the main thread. Your answers here stay in this thread only and will NOT be added back to the main conversation.",s=this.parentSession.messages.slice(0,this.threadAnchorIndex+1),n=["## Conversation so far"];for(let r of s){let i=r.role==="user"?"User":"Assistant";n.push(`${i}: ${r.content.trim()}`)}e.push(`## Thread Mode +${t} -`)}`)}}if(this.agent.skillsBody.trim()&&t.push(`## Agent Skills -${this.agent.skillsBody.trim()}`),this.agent.contextBody.trim()&&t.push(`## Agent Context -${this.agent.contextBody.trim()}`),this.agent.memory){let s=await this.repository.readWorkingMemory(this.agent.name),n=Gs(this.agent,s);n&&t.push(n)}this.channelContext&&this.channelContext.trim()&&t.push(`## Channel Context -${this.channelContext.trim()}`);let e=rn(this.agent,this.repository);if(e&&t.push(e),this.isThread&&this.parentSession&&this.threadAnchorIndex!==void 0){let s="You are continuing a side thread from this conversation. The user is following up on one of your earlier replies and wants to explore something specific without adding to the main thread. Your answers here stay in this thread only and will NOT be added back to the main conversation.",n=this.parentSession.messages.slice(0,this.threadAnchorIndex+1),a=["## Conversation so far"];for(let i of n){let o=i.role==="user"?"User":"Assistant";a.push(`${o}: ${i.content.trim()}`)}t.push(`## Thread Mode -${s} +${n.join(` +`)}`)}return e.filter(Boolean).join(` -${a.join(` -`)}`)}return t.filter(Boolean).join(` +`)}async openOrCreateThread(e){if(this.isThread)throw new Error("Nested threads are not supported.");let t=this.threads.get(e);if(t)return t;let s=this.messages.findIndex(i=>i.id===e);if(s<0)throw new Error(`Thread anchor message "${e}" not found in parent.`);let n=new a(this.agent,this.settings,this.repository,this.vault,{threadAnchorId:e,parentSession:this,mcpAuth:this.mcpAuth});return n.threadAnchorIndex=s,await n.loadPersistedState()||(n.threadAnchorIndex=s),this.threads.set(e,n),n}closeThread(e){let t=this.threads.get(e);t&&(t.abort(),this.threads.delete(e))}hibernateIdleThreads(e){let t=Date.now();for(let s of this.threads.values())s.isProcessAlive&&t-s.lastActiveAt>e&&s.hibernate()}parseStreamEvent(e){let t=e.type;if(t==="assistant"){let s=e.message;if(s?.content&&Array.isArray(s.content))for(let n of s.content){if(n.type==="text"&&typeof n.text=="string")return{type:"text",content:n.text};if(n.type==="tool_use"){let r=String(n.name??"tool"),i=n.input,o=i?.command??i?.content??i?.file_path??i?.path??"";return{type:"tool_use",content:o?String(o).slice(0,150):"",toolName:r}}}}if(t==="content_block_delta"){let s=e.delta;if(s?.type==="text_delta"&&typeof s.text=="string")return{type:"text",content:s.text}}return null}};var sa=class{constructor(e){this.config=e;this.now=e.now??Date.now}buckets=new Map;now;tryConsume(e){let t=this.now(),s=t-this.config.windowMs,r=(this.buckets.get(e)??[]).filter(i=>i>s);return r.length>=this.config.maxPerWindow?(this.buckets.set(e,r),!1):(r.push(t),this.buckets.set(e,r),!0)}currentCount(e){let s=this.now()-this.config.windowMs;return(this.buckets.get(e)??[]).filter(r=>r>s).length}reset(e){this.buckets.delete(e)}resetAll(){this.buckets.clear()}};function Qs(a){let e=Gd(a),t=[];for(let s of e)if(s.kind==="code"){let n=s.text.replace(/^```[^\n]*\n/,"```\n");t.push(n)}else t.push(Vd(s.text));return t.join("")}function Gd(a){let e=[],t=0,s=!1,n=0;for(;tn&&e.push({kind:"prose",text:a.slice(n,t)}),n=t,s=!0,t+=3):t+=1;return n{if(s.isCode)return s.text;let n=s.text;return n=n.replace(/&/g,"&").replace(//g,">"),n=n.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(r,i,o)=>`<${o}|${i}>`),n=n.replace(/\*\*([^*]+)\*\*/g,"*$1*"),n=n.replace(/^#{1,6}\s+(.+)$/gm,"*$1*"),n}).join("")}function Yd(a){let e=[],t=/`([^`\n]+)`/g,s=0,n;for(;n=t.exec(a);)n.index>s&&e.push({text:a.slice(s,n.index),isCode:!1}),e.push({text:n[0],isCode:!0}),s=n.index+n[0].length;return se;){let n=s.slice(0,e),r=Kd(n)%2===1,i;if(r){let o=Jd(n);if(o>0)i=o;else{t.push(n+"\n```"),s="```\n"+s.slice(e);continue}}else{if(i=n.lastIndexOf(` -`)}async openOrCreateThread(t){if(this.isThread)throw new Error("Nested threads are not supported.");let e=this.threads.get(t);if(e)return e;let s=this.messages.findIndex(i=>i.id===t);if(s<0)throw new Error(`Thread anchor message "${t}" not found in parent.`);let n=new r(this.agent,this.settings,this.repository,this.vault,{threadAnchorId:t,parentSession:this,mcpAuth:this.mcpAuth});return n.threadAnchorIndex=s,await n.loadPersistedState()||(n.threadAnchorIndex=s),this.threads.set(t,n),n}closeThread(t){let e=this.threads.get(t);e&&(e.abort(),this.threads.delete(t))}hibernateIdleThreads(t){let e=Date.now();for(let s of this.threads.values())s.isProcessAlive&&e-s.lastActiveAt>t&&s.hibernate()}parseStreamEvent(t){let e=t.type;if(e==="assistant"){let s=t.message;if(s?.content&&Array.isArray(s.content))for(let n of s.content){if(n.type==="text"&&typeof n.text=="string")return{type:"text",content:n.text};if(n.type==="tool_use"){let a=String(n.name??"tool"),i=n.input,o=i?.command??i?.content??i?.file_path??i?.path??"";return{type:"tool_use",content:o?String(o).slice(0,150):"",toolName:a}}}}if(e==="content_block_delta"){let s=t.delta;if(s?.type==="text_delta"&&typeof s.text=="string")return{type:"text",content:s.text}}return null}};var vn=class{constructor(t){this.config=t;this.now=t.now??Date.now}buckets=new Map;now;tryConsume(t){let e=this.now(),s=e-this.config.windowMs,a=(this.buckets.get(t)??[]).filter(i=>i>s);return a.length>=this.config.maxPerWindow?(this.buckets.set(t,a),!1):(a.push(e),this.buckets.set(t,a),!0)}currentCount(t){let s=this.now()-this.config.windowMs;return(this.buckets.get(t)??[]).filter(a=>a>s).length}reset(t){this.buckets.delete(t)}resetAll(){this.buckets.clear()}};function Ts(r){let t=pc(r),e=[];for(let s of t)if(s.kind==="code"){let n=s.text.replace(/^```[^\n]*\n/,"```\n");e.push(n)}else e.push(mc(s.text));return e.join("")}function pc(r){let t=[],e=0,s=!1,n=0;for(;en&&t.push({kind:"prose",text:r.slice(n,e)}),n=e,s=!0,e+=3):e+=1;return n{if(s.isCode)return s.text;let n=s.text;return n=n.replace(/&/g,"&").replace(//g,">"),n=n.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(a,i,o)=>`<${o}|${i}>`),n=n.replace(/\*\*([^*]+)\*\*/g,"*$1*"),n=n.replace(/^#{1,6}\s+(.+)$/gm,"*$1*"),n}).join("")}function fc(r){let t=[],e=/`([^`\n]+)`/g,s=0,n;for(;n=e.exec(r);)n.index>s&&t.push({text:r.slice(s,n.index),isCode:!1}),t.push({text:n[0],isCode:!0}),s=n.index+n[0].length;return st;){let n=s.slice(0,t),a=gc(n)%2===1,i;if(a){let o=yc(n);if(o>0)i=o;else{e.push(n+"\n```"),s="```\n"+s.slice(t);continue}}else{if(i=n.lastIndexOf(` +`),ie/2?i=o:i=e}i<=0&&(i=e)}t.push(s.slice(0,i)),s=s.slice(i).replace(/^\n+/,"")}return s.length>0&&t.push(s),t}function ss(a,e){if(a.length<=e)return[a];let t=[],s=a;for(;s.length>e;){let n=s.lastIndexOf(` -`),it/2?i=o:i=t}i<=0&&(i=t)}e.push(s.slice(0,i)),s=s.slice(i).replace(/^\n+/,"")}return s.length>0&&e.push(s),e}function gc(r){let t=0,e=0;for(;(e=r.indexOf("```",e))!==-1;)t+=1,e+=3;return t}function yc(r){let t=0,e=0,s=0;for(;sDate.now());let e=t.getSettings();this.rateLimiter=new vn({maxPerWindow:Math.max(1,e.channelRateLimitPerConversation),windowMs:Math.max(1e3,e.channelRateLimitWindowMinutes*6e4),now:this.now})}adapters=new Map;adapterConfigs=new Map;sessions=new Map;conversationLocks=new Map;rateLimiter;metrics=new Map;statusListeners=new Set;adapterUnsubscribes=new Map;now;threadBindings=new Map;activeTurns=new Map;turnTails=new Map;hibernationInterval=null;started=!1;async start(t){if(!this.started){this.started=!0;for(let e of t.channels)await this.bringUpChannel(e,t);this.hibernationInterval=window.setInterval(()=>{this.runHibernationSweep()},6e4)}}async stop(){if(!this.started)return;this.started=!1,this.hibernationInterval&&(window.clearInterval(this.hibernationInterval),this.hibernationInterval=null);let t=Array.from(this.adapters.values());await Promise.all(t.map(async e=>{try{await e.stop()}catch(s){console.warn(`Agent Fleet: channel adapter ${e.config.name} stop() failed`,s)}})),this.adapters.clear(),this.adapterConfigs.clear();for(let e of this.adapterUnsubscribes.values())for(let s of e)s();this.adapterUnsubscribes.clear(),this.conversationLocks.size>0&&await Promise.allSettled(Array.from(this.conversationLocks.values()));for(let e of this.sessions.values())try{e.session.isStreaming?e.session.abort():e.session.hibernate()}catch{}this.sessions.clear(),this.conversationLocks.clear(),this.conversationLockGen.clear(),this.rateLimiter.resetAll()}async reconcile(t){if(!this.started)return;let e=new Map;for(let s of t.channels)e.set(s.name,s);for(let[s,n]of this.adapters){let a=e.get(s),i=a&&this.isChannelRuntimeValid(a,t);if(!a||!a.enabled||!i){await this.tearDownChannel(s);continue}let o=this.adapterConfigs.get(s);o&&this.requiresRestart(o,a)?(await this.tearDownChannel(s),await this.bringUpChannel(a,t)):(this.adapterConfigs.set(s,a),n.config=a)}for(let[s,n]of e)!this.adapters.has(s)&&n.enabled&&this.isChannelRuntimeValid(n,t)&&await this.bringUpChannel(n,t);this.notifyStatusListeners()}getCredentials(){return this.deps.getChannelCredentials?.()??this.deps.getSettings().channelCredentials??{}}isChannelRuntimeValid(t,e){let s=e.agents.find(a=>a.name===t.defaultAgent);if(!s||s.approvalRequired.length>0)return!1;let n=this.getCredentials()[t.credentialRef];return!(!n||n.type!==t.type)}async bringUpChannel(t,e){if(!t.enabled||!this.isChannelRuntimeValid(t,e))return;let s=this.getCredentials()[t.credentialRef];if(!s)return;let n;try{n=this.deps.adapterFactory(t,s)}catch(i){console.error(`Agent Fleet: failed to build adapter for channel ${t.name}`,i);return}let a=[];a.push(n.onInbound(i=>{this.handleInbound(n,i)})),a.push(n.onStatusChange(()=>this.notifyStatusListeners())),n.onAgentSwitch&&a.push(n.onAgentSwitch((i,o,c)=>{let l=`${t.name}:${i}`;this.threadBindings.set(l,o),this.persistBindings(t.name)})),n.setAllowedAgentsResolver?.(()=>this.resolveAllowedAgents(t)),this.adapters.set(t.name,n),this.adapterConfigs.set(t.name,t),this.adapterUnsubscribes.set(t.name,a),this.ensureMetrics(t.name),await this.loadBindings(t.name);try{await n.start()}catch(i){console.error(`Agent Fleet: channel adapter ${t.name} start() failed`,i)}this.notifyStatusListeners()}async tearDownChannel(t){let e=this.adapters.get(t);if(!e)return;try{await e.stop()}catch(a){console.warn(`Agent Fleet: channel adapter ${t} stop() failed`,a)}let s=this.adapterUnsubscribes.get(t);if(s)for(let a of s)a();this.adapterUnsubscribes.delete(t),this.adapters.delete(t),this.adapterConfigs.delete(t);let n=`${t}:`;for(let[a,i]of this.sessions)if(a.startsWith(n)){try{i.session.isStreaming?i.session.abort():i.session.hibernate()}catch{}this.sessions.delete(a)}}requiresRestart(t,e){return t.type!==e.type||t.credentialRef!==e.credentialRef||JSON.stringify(t.transport)!==JSON.stringify(e.transport)}async handleInbound(t,e){let s=t.config,n=`${s.name}:${e.conversationId}`;if(!(s.allowedUsers.length>0&&(!e.externalUserId||!s.allowedUsers.includes(e.externalUserId)))){if(!this.rateLimiter.tryConsume(n)){console.warn(`Agent Fleet: rate-limited message from ${e.externalUserId} on ${s.name} (conversation ${e.conversationId})`);try{await t.send(e.conversationId,"_Rate limit exceeded. Please slow down and try again in a few minutes._")}catch(a){console.warn(`Agent Fleet: rate-limit reply failed on ${s.name}`,a)}return}await this.withConversationLock(n,async()=>{let a=this.ensureMetrics(s.name);a.messagesReceived+=1,a.lastMessageAt=this.now();let i=this.resolveAllowedAgents(s),o=vc(e.text,i),c,l;if(o){c=o.agent,l=o.rest;let m=this.threadBindings.get(n);if(this.threadBindings.set(n,c),this.persistBindings(s.name),m!==c)try{await t.setThreadTitle?.(e.conversationId,c)}catch{}if(!l){try{await t.send(e.conversationId,`_Now chatting with *${c}*. Send your next message to start._`)}catch{}return}}else{let m=`${s.name}:${e.conversationId.replace(/:thread:[^:]+$/,`:user:${e.externalUserId}`)}`;c=this.threadBindings.get(n)??this.threadBindings.get(m)??s.defaultAgent,l=e.text}if(e.images&&e.images.length>0){let m=await this.saveInboundImages(e.images);m&&(l=m+(l||"Please analyze this image."))}let h=this.activeTurns.get(n);if(h&&h.agent===c&&h.session.isStreaming){h.session.injectMessage(l);return}let d=await this.getOrCreateSession(s,e.conversationId,c),p=(this.turnTails.get(n)??Promise.resolve()).catch(()=>{}).then(()=>(this.activeTurns.set(n,{agent:c,session:d}),this.runChannelTurn(t,s,e.conversationId,c,d,l).finally(()=>{this.activeTurns.delete(n)})));this.turnTails.set(n,p),p.finally(()=>{this.turnTails.get(n)===p&&this.turnTails.delete(n)})})}}async runChannelTurn(t,e,s,n,a,i){let o=this.ensureMetrics(e.name),c=e.allowedAgents.length>1||e.allowedAgents.length===0&&this.resolveAllowedAgents(e).length>1;try{await t.setTyping(s,!0)}catch{}let l=Promise.resolve(),h=m=>{l=l.then(async()=>{try{await this.deliverReply(t,s,m),o.messagesSent+=1}catch(f){console.error(`Agent Fleet: reply delivery failed on ${e.name}`,f)}})},d="",u=[],p=()=>{let m=At(d).trim();if(u.length>0){let f=wc(u);f&&(m+=`${m?` +`,e);nDate.now());let t=e.getSettings();this.rateLimiter=new sa({maxPerWindow:Math.max(1,t.channelRateLimitPerConversation),windowMs:Math.max(1e3,t.channelRateLimitWindowMinutes*6e4),now:this.now})}adapters=new Map;adapterConfigs=new Map;sessions=new Map;conversationLocks=new Map;rateLimiter;metrics=new Map;statusListeners=new Set;adapterUnsubscribes=new Map;now;threadBindings=new Map;activeTurns=new Map;turnTails=new Map;hibernationInterval=null;started=!1;async start(e){if(!this.started){this.started=!0;for(let t of e.channels)await this.bringUpChannel(t,e);this.hibernationInterval=window.setInterval(()=>{this.runHibernationSweep()},6e4)}}async stop(){if(!this.started)return;this.started=!1,this.hibernationInterval&&(window.clearInterval(this.hibernationInterval),this.hibernationInterval=null);let e=Array.from(this.adapters.values());await Promise.all(e.map(async t=>{try{await t.stop()}catch(s){console.warn(`Agent Fleet: channel adapter ${t.config.name} stop() failed`,s)}})),this.adapters.clear(),this.adapterConfigs.clear();for(let t of this.adapterUnsubscribes.values())for(let s of t)s();if(this.adapterUnsubscribes.clear(),this.conversationLocks.size>0&&await Promise.allSettled(Array.from(this.conversationLocks.values())),this.turnTails.size>0){let t=null,s=new Promise(n=>{t=window.setTimeout(n,1e4)});await Promise.race([Promise.allSettled(Array.from(this.turnTails.values())).then(()=>{}),s]),t!==null&&window.clearTimeout(t)}for(let t of this.sessions.values())try{t.session.isStreaming?t.session.abort():t.session.hibernate()}catch{}this.sessions.clear(),this.conversationLocks.clear(),this.conversationLockGen.clear(),this.rateLimiter.resetAll()}async reconcile(e){if(!this.started)return;let t=new Map;for(let s of e.channels)t.set(s.name,s);for(let[s,n]of this.adapters){let r=t.get(s),i=r&&this.isChannelRuntimeValid(r,e);if(!r||!r.enabled||!i){await this.tearDownChannel(s);continue}let o=this.adapterConfigs.get(s);o&&this.requiresRestart(o,r)?(await this.tearDownChannel(s),await this.bringUpChannel(r,e)):(this.adapterConfigs.set(s,r),n.config=r)}for(let[s,n]of t)!this.adapters.has(s)&&n.enabled&&this.isChannelRuntimeValid(n,e)&&await this.bringUpChannel(n,e);this.notifyStatusListeners()}getCredentials(){return this.deps.getChannelCredentials?.()??this.deps.getSettings().channelCredentials??{}}isChannelRuntimeValid(e,t){let s=t.agents.find(r=>r.name===e.defaultAgent);if(!s||s.approvalRequired.length>0)return!1;let n=this.getCredentials()[e.credentialRef];return!(!n||n.type!==e.type)}async bringUpChannel(e,t){if(!e.enabled||!this.isChannelRuntimeValid(e,t))return;let s=this.getCredentials()[e.credentialRef];if(!s)return;let n;try{n=this.deps.adapterFactory(e,s)}catch(i){console.error(`Agent Fleet: failed to build adapter for channel ${e.name}`,i);return}let r=[];r.push(n.onInbound(i=>{this.handleInbound(n,i)})),r.push(n.onStatusChange(()=>this.notifyStatusListeners())),n.onAgentSwitch&&r.push(n.onAgentSwitch((i,o,l)=>{let c=`${e.name}:${i}`;this.threadBindings.set(c,o),this.persistBindings(e.name)})),n.setAllowedAgentsResolver?.(()=>this.resolveAllowedAgents(e)),this.adapters.set(e.name,n),this.adapterConfigs.set(e.name,e),this.adapterUnsubscribes.set(e.name,r),this.ensureMetrics(e.name),await this.loadBindings(e.name);try{await n.start()}catch(i){console.error(`Agent Fleet: channel adapter ${e.name} start() failed`,i)}this.notifyStatusListeners()}async tearDownChannel(e){let t=this.adapters.get(e);if(!t)return;try{await t.stop()}catch(r){console.warn(`Agent Fleet: channel adapter ${e} stop() failed`,r)}let s=this.adapterUnsubscribes.get(e);if(s)for(let r of s)r();this.adapterUnsubscribes.delete(e),this.adapters.delete(e),this.adapterConfigs.delete(e);let n=`${e}:`;for(let[r,i]of this.sessions)if(r.startsWith(n)){try{i.session.isStreaming?i.session.abort():i.session.hibernate()}catch{}this.sessions.delete(r)}}requiresRestart(e,t){return e.type!==t.type||e.credentialRef!==t.credentialRef||JSON.stringify(e.transport)!==JSON.stringify(t.transport)}async handleInbound(e,t){let s=e.config,n=`${s.name}:${t.conversationId}`;if(!(s.allowedUsers.length>0&&(!t.externalUserId||!s.allowedUsers.includes(t.externalUserId)))){if(!this.rateLimiter.tryConsume(n)){console.warn(`Agent Fleet: rate-limited message from ${t.externalUserId} on ${s.name} (conversation ${t.conversationId})`);try{await e.send(t.conversationId,"_Rate limit exceeded. Please slow down and try again in a few minutes._")}catch(r){console.warn(`Agent Fleet: rate-limit reply failed on ${s.name} (user ${t.externalUserId}, conversation ${t.conversationId})`,r)}return}await this.withConversationLock(n,async()=>{let r=this.ensureMetrics(s.name);r.messagesReceived+=1,r.lastMessageAt=this.now();let i=this.resolveAllowedAgents(s),o=Xd(t.text,i),l,c;if(o){l=o.agent,c=o.rest;let m=this.threadBindings.get(n);if(this.threadBindings.set(n,l),this.persistBindings(s.name),m!==l)try{await e.setThreadTitle?.(t.conversationId,l)}catch{}if(!c){try{await e.send(t.conversationId,`_Now chatting with *${l}*. Send your next message to start._`)}catch{}return}}else{let m=`${s.name}:${t.conversationId.replace(/:thread:[^:]+$/,`:user:${t.externalUserId}`)}`;l=this.threadBindings.get(n)??this.threadBindings.get(m)??s.defaultAgent,c=t.text}if(t.images&&t.images.length>0){let m=await this.saveInboundImages(t.images);m&&(c=m+(c||"Please analyze this image."))}let d=this.activeTurns.get(n);if(d&&d.agent===l&&d.session.isStreaming){d.session.injectMessage(c);return}let u=await this.getOrCreateSession(s,t.conversationId,l),p=(this.turnTails.get(n)??Promise.resolve()).catch(()=>{}).then(()=>(this.activeTurns.set(n,{agent:l,session:u}),this.runChannelTurn(e,s,t.conversationId,l,u,c).finally(()=>{this.activeTurns.delete(n)})));this.turnTails.set(n,p),p.finally(()=>{this.turnTails.get(n)===p&&this.turnTails.delete(n)})})}}async runChannelTurn(e,t,s,n,r,i){let o=this.ensureMetrics(t.name),l=t.allowedAgents.length>1||t.allowedAgents.length===0&&this.resolveAllowedAgents(t).length>1;try{await e.setTyping(s,!0)}catch{}let c=Promise.resolve(),d=m=>{c=c.then(async()=>{try{await this.deliverReply(e,s,m),o.messagesSent+=1}catch(f){console.error(`Agent Fleet: reply delivery failed on ${t.name}`,f)}})},u="",h=[],p=()=>{let m=Jt(u).trim();if(h.length>0){let f=Qd(h);f&&(m+=`${m?` -`:""}_${f}_`)}d="",u=[],m&&(c&&(m=`*[${n}]* -${m}`),h(m))};try{await a.sendMessage(i,m=>{if(m.type==="text")d+=m.content;else if(m.type==="tool_use"&&m.toolName)u.push({name:m.toolName});else if(m.type==="error"){let f=m.errorMessage?.trim()||"the agent run failed";h(`_Sorry \u2014 ${f}_`)}else m.type==="result"&&p()})}catch(m){console.error(`Agent Fleet: channel turn failed on ${e.name}/${s}`,m),h(`_Sorry \u2014 the agent run failed. ${m instanceof Error?m.message:String(m)}_`)}finally{await l;try{await t.setTyping(s,!1)}catch{}this.enforceHardCap()}}async deliverReply(t,e,s){let n=ki(s);for(let a of n)await t.send(e,a)}async saveInboundImages(t){let s=`${this.deps.getSettings().fleetFolder}/chat-images`,n=[];for(let a of t)try{this.deps.vault.getAbstractFileByPath((0,ut.normalizePath)(s))||await this.deps.vault.createFolder((0,ut.normalizePath)(s));let i=(0,ut.normalizePath)(`${s}/${a.filename}`),o=i;if(this.deps.vault.getAbstractFileByPath(i)){let d=a.filename.lastIndexOf("."),u=d>0?a.filename.slice(0,d):a.filename,p=d>0?a.filename.slice(d):"";o=(0,ut.normalizePath)(`${s}/${u}_${Date.now()}${p}`)}await this.deps.vault.createBinary(o,a.data);let l=this.deps.vault.adapter.basePath??"",h=l?`${l}/${o}`:o;n.push(`### Image: ${a.filename} -The image file is located at: ${h} -Please read and analyze this image.`)}catch(i){console.warn("Agent Fleet: failed to save inbound image",a.filename,i)}return n.length===0?"":`## Attached Images +`:""}_${f}_`)}u="",h=[],m&&(l&&(m=`*[${n}]* +${m}`),d(m))};try{await r.sendMessage(i,m=>{if(m.type==="text")u+=m.content;else if(m.type==="tool_use"&&m.toolName)h.push({name:m.toolName});else if(m.type==="error"){let f=m.errorMessage?.trim()||"the agent run failed";d(`_Sorry \u2014 ${f}_`)}else m.type==="result"&&p()})}catch(m){console.error(`Agent Fleet: channel turn failed on ${t.name}/${s}`,m),d(`_Sorry \u2014 the agent run failed. ${m instanceof Error?m.message:String(m)}_`)}finally{await c;try{await e.setTyping(s,!1)}catch{}this.enforceHardCap()}}async deliverReply(e,t,s){let n=Co(s);for(let r of n)await e.send(t,r)}async saveInboundImages(e){let s=`${this.deps.getSettings().fleetFolder}/chat-images`,n=[];for(let r of e)try{this.deps.vault.getAbstractFileByPath((0,xt.normalizePath)(s))||await this.deps.vault.createFolder((0,xt.normalizePath)(s));let i=(0,xt.normalizePath)(`${s}/${r.filename}`),o=i;if(this.deps.vault.getAbstractFileByPath(i)){let u=r.filename.lastIndexOf("."),h=u>0?r.filename.slice(0,u):r.filename,p=u>0?r.filename.slice(u):"";o=(0,xt.normalizePath)(`${s}/${h}_${Date.now()}${p}`)}await this.deps.vault.createBinary(o,r.data);let c=this.deps.vault.adapter.basePath??"",d=c?`${c}/${o}`:o;n.push(`### Image: ${r.filename} +The image file is located at: ${d} +Please read and analyze this image.`)}catch(i){console.warn("Agent Fleet: failed to save inbound image",r.filename,i)}return n.length===0?"":`## Attached Images ${n.join(` @@ -11940,26 +11936,12 @@ ${n.join(` --- -`}async getOrCreateSession(t,e,s){let n=`${t.name}:${e}:${s}`,a=this.sessions.get(n);if(a)return a.session;let i=this.deps.getRepository(),o=i.getAgentByName(s);if(!o)throw new Error(`Channel ${t.name} bound to missing agent ${s}`);let c=new Yt(o,this.deps.getSettings(),i,this.deps.vault,{channelName:t.name,conversationId:`${e}:${s}`,channelContext:t.channelContext||void 0,mcpAuth:this.deps.getMcpAuth?.()});this.deps.recordUsage&&c.setUsageRecorder(this.deps.recordUsage);try{await c.loadPersistedState()}catch{}return this.sessions.set(n,{session:c,channelName:t.name,conversationId:e,sessionKey:n}),c}resolveAllowedAgents(t){let e=new Set(this.deps.getRepository().getSnapshot().agents.filter(n=>n.enabled).map(n=>n.name));return(t.allowedAgents.length>0?t.allowedAgents:[...e]).filter(n=>e.has(n))}async persistBindings(t){let e=`${t}:`,s={};for(let[o,c]of this.threadBindings)o.startsWith(e)&&(s[o.slice(e.length)]=c);let n=this.deps.getSettings(),a=(0,ut.normalizePath)(`${n.fleetFolder}/channels/${t}/bindings.json`),i=JSON.stringify(s,null,2);try{let o=this.deps.vault.getAbstractFileByPath(a);if(o instanceof ut.TFile)await this.deps.vault.modify(o,i);else{let c=a.slice(0,a.lastIndexOf("/"));if(!this.deps.vault.getAbstractFileByPath(c))try{await this.deps.vault.createFolder(c)}catch{}await this.deps.vault.create(a,i)}}catch(o){console.warn(`Agent Fleet: failed to persist thread bindings for ${t}`,o)}}async loadBindings(t){let e=this.deps.getSettings(),s=(0,ut.normalizePath)(`${e.fleetFolder}/channels/${t}/bindings.json`);try{let n=this.deps.vault.getAbstractFileByPath(s);if(!(n instanceof ut.TFile))return;let a=await this.deps.vault.cachedRead(n),i=JSON.parse(a);for(let[o,c]of Object.entries(i))typeof c=="string"&&this.threadBindings.set(`${t}:${o}`,c)}catch{}}getThreadAgent(t,e){return this.threadBindings.get(`${t}:${e}`)}enforceHardCap(){let t=Math.max(1,this.deps.getSettings().maxConcurrentChannelSessions),e=Array.from(this.sessions.values()).filter(n=>n.session.isProcessAlive&&!n.session.isStreaming);if(e.length<=t)return;e.sort((n,a)=>n.session.lastActiveAt-a.session.lastActiveAt);let s=e.length-t;for(let n=0;n{n=o}),i=(this.conversationLockGen.get(t)??0)+1;this.conversationLockGen.set(t,i),this.conversationLocks.set(t,s.then(()=>a));try{await s,await e()}finally{n(),this.conversationLockGen.get(t)===i&&(this.conversationLocks.delete(t),this.conversationLockGen.delete(t))}}ensureMetrics(t){let e=this.metrics.get(t);return e||(e={messagesReceived:0,messagesSent:0,lastMessageAt:null},this.metrics.set(t,e)),e}getMetrics(t){return{...this.ensureMetrics(t)}}getChannelStatus(t){let e=this.adapters.get(t);return e?e.getStatus():"disabled"}getConnectedCount(){let t=0;for(let e of this.adapters.values())e.getStatus()==="connected"&&(t+=1);return t}getSessionCount(t){let e=0,s=`${t}:`;for(let n of this.sessions.keys())n.startsWith(s)&&(e+=1);return e}onStatusChange(t){return this.statusListeners.add(t),()=>this.statusListeners.delete(t)}notifyStatusListeners(){for(let t of this.statusListeners)try{t()}catch{}}};function wc(r){if(r.length===0)return"";let t=new Map;for(let s of r)t.set(s.name,(t.get(s.name)??0)+1);let e=[];for(let[s,n]of t)e.push(n>1?`${s}\xD7${n}`:s);return`Used ${r.length} tool${r.length===1?"":"s"}: ${e.join(", ")}`}var bn=class{credentials=new Map;secretStore;persistCallback;setSecretStore(t){this.secretStore=t}loadCredentials(t){if(this.credentials.clear(),this.secretStore?.available){let e=this.secretStore.listByPrefix(Vt);for(let s of e){let n=Vt+"-",a=s.startsWith(n)?s.slice(n.length):s,i=this.secretStore.getJson(Vt,a);if(i){let o=i._ref??a,c={...i};delete c._ref,this.credentials.set(o,c)}}}if(this.credentials.size===0&&t){for(let[e,s]of Object.entries(t))this.credentials.set(e,s);this.secretStore?.available&&this.credentials.size>0&&this.persistToSecretStore()}}onChanged(t){this.persistCallback=t}get(t){return this.credentials.get(t)}set(t,e){this.credentials.set(t,e),this.persist()}delete(t){this.credentials.delete(t),this.secretStore?.delete(Vt,t),this.persist()}list(){return Array.from(this.credentials.entries()).map(([t,e])=>({ref:t,entry:e}))}toRecord(){let t={};for(let[e,s]of this.credentials.entries())t[e]=s;return t}persist(){this.persistToSecretStore(),this.persistCallback&&this.persistCallback(this.toRecord())}persistToSecretStore(){if(this.secretStore?.available)for(let[t,e]of this.credentials)this.secretStore.setJson(Vt,t,{...e,_ref:t})}};function Si(){return{status:"disconnected",scope:"user",tools:[],toolDetails:[]}}function bc(r,t,e){if(e)return"stdio";let s=(r??"").toLowerCase();return s==="sse"?"sse":s==="http"||s==="streamable-http"||s==="streamable_http"?"http":t?.endsWith("/sse")?"sse":"http"}function Ci(r){let t=[],e={},s;try{s=JSON.parse(r)}catch{return{servers:t,tokens:e}}let n=s.mcpServers;if(!n||typeof n!="object")return{servers:t,tokens:e};for(let[a,i]of Object.entries(n)){if(!i||typeof i!="object")continue;let o=i,c=typeof o.command=="string"?o.command:void 0,l=typeof o.url=="string"?o.url:void 0;if(!c&&!l)continue;let h=bc(typeof o.type=="string"?o.type:void 0,l,!!c),d={name:a,type:h,enabled:!0,source:"imported",...Si()};if(h==="stdio")d.command=c,Array.isArray(o.args)&&(d.args=o.args.filter(u=>typeof u=="string")),o.env&&typeof o.env=="object"&&(d.env=xi(o.env));else{d.url=l;let u=o.headers&&typeof o.headers=="object"?xi(o.headers):{},p=u.Authorization??u.authorization;p?.startsWith("Bearer ")?(e[a]=p.slice(7),delete u.Authorization,delete u.authorization,d.auth="oauth"):d.auth="none",Object.keys(u).length>0&&(d.headers=u)}t.push(d)}return{servers:t,tokens:e}}function Ti(r){let t=new Map,e=null;for(let n of r.split(/\r?\n/)){let a=n.trim();if(!a||a.startsWith("#"))continue;let i=a.match(/^\[(.+)\]$/);if(i){let u=i[1].trim().match(/^mcp_servers\.(.+)$/);if(!u){e=null;continue}let p=u[1],m=null;p.endsWith(".env")?(m="env",p=p.slice(0,-4)):p.endsWith(".oauth")&&(m="oauth",p=p.slice(0,-6));let f=kn(p);if(!f){e=null;continue}t.has(f)||t.set(f,{name:f,enabled:!0}),e={name:f,sub:m};continue}if(!e)continue;let o=a.match(/^([^=]+?)\s*=\s*(.+)$/);if(!o)continue;let c=kn(o[1].trim()),l=o[2].trim(),h=t.get(e.name);if(e.sub==="env")h.env??={},h.env[c]=Kt(l);else if(e.sub==="oauth")c==="client_id"&&(h.oauthClientId=Kt(l));else switch(c){case"command":h.command=Kt(l);break;case"args":h.args=kc(l);break;case"url":h.url=Kt(l);break;case"bearer_token_env_var":h.bearerEnvVar=Kt(l);break;case"oauth_resource":h.oauthResource=Kt(l);break;case"enabled":h.enabled=l==="true";break;default:break}}let s=[];for(let n of t.values()){if(!n.command&&!n.url)continue;let a=n.command?"stdio":n.url?.endsWith("/sse")?"sse":"http",i={name:n.name,type:a,enabled:n.enabled,source:"imported",...Si()};a==="stdio"?(i.command=n.command,n.args&&n.args.length>0&&(i.args=n.args),n.env&&Object.keys(n.env).length>0&&(i.env=n.env)):(i.url=n.url,n.oauthClientId||n.oauthResource?(i.auth="oauth",i.oauth={clientId:n.oauthClientId,resource:n.oauthResource}):n.bearerEnvVar?(i.auth="bearer",i.envSecretKeys=[n.bearerEnvVar]):i.auth="none"),s.push(i)}return{servers:s,tokens:{}}}function _i(...r){let t=new Map,e={};for(let s of r){for(let n of s.servers){let a=n.name.trim().toLowerCase();t.has(a)||t.set(a,n)}Object.assign(e,s.tokens)}return{servers:[...t.values()],tokens:e}}function kn(r){let t=r.trim();return t.startsWith('"')&&t.endsWith('"')||t.startsWith("'")&&t.endsWith("'")?t.slice(1,-1):t}function Kt(r){let t=r.trim();if(t.startsWith('"')||t.startsWith("'")){let e=t[0],s=t.indexOf(e,1);if(s>0)return t.slice(1,s)}return kn(t.replace(/\s+#.*$/,""))}function kc(r){let t=r.trim();try{let s=JSON.parse(t);if(Array.isArray(s))return s.filter(n=>typeof n=="string")}catch{}return t.replace(/^\[/,"").replace(/\]$/,"").split(",").map(s=>kn(s.trim())).filter(Boolean)}function xi(r){let t={};for(let[e,s]of Object.entries(r))typeof s=="string"&&(t[e]=s);return t}var Wd=Ye(xo(),1),Hd=Ye(Rn(),1),qd=Ye(Qt(),1),zd=Ye(La(),1),Gd=Ye(Na(),1),Vd=Ye(Ha(),1),Do=Ye(Ln(),1),Yd=Ye(Ro(),1);var as=Do.default;var Io=require("obsidian");var Kd="https://slack.com/api";function Jd(r){let t=r.team??"unknown",e=r.channel??"unknown",s=r.thread_ts??r.ts??"unknown";return`slack:${t}:${e}:thread:${s}`}function Xd(r){let t=r.split(":");return t.length>=3&&t[0]==="slack"?t[2]??null:null}function Qd(r){let t=r.split(":");if(t[3]==="thread"&&t[4])return t[4]}var On=class{type="slack";config;credential;ws=null;status="stopped";stopping=!1;backoffMs=1e3;reconnectTimer=null;inboundHandlers=new Set;statusHandlers=new Set;agentSwitchHandlers=new Set;allowedAgentsResolver;sendQueues=new Map;threadContext=new Map;constructor(t,e){if(e.type!=="slack")throw new Error(`SlackAdapter requires a slack credential, got ${e.type}`);this.config=t,this.credential=e}async start(){this.stopping=!1,await this.connect()}async stop(){if(this.stopping=!0,this.reconnectTimer&&(window.clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.ws){try{this.ws.close()}catch{}this.ws=null}this.threadContext.clear(),this.sendQueues.clear(),this.setStatus("stopped")}getStatus(){return this.status}async send(t,e){let s=Xd(t);if(!s){console.warn(`Agent Fleet: could not extract channel id from ${t}`);return}let n=Qd(t),a=Ts(e);await this.enqueueSend(s,async()=>{await this.slackApi("chat.postMessage",{channel:s,text:a,...n?{thread_ts:n}:{}})})}async sendToTarget(t,e){if(!t)return;let s=Ts(e);await this.enqueueSend(t,async()=>{await this.slackApi("chat.postMessage",{channel:t,text:s})})}async broadcast(t){let e=this.config.allowedUsers[0];if(!e){console.warn(`Agent Fleet: broadcast on ${this.config.name} skipped \u2014 no allowed users configured`);return}try{let n=(await this.slackApi("conversations.open",{users:e})).channel?.id;if(!n){console.warn(`Agent Fleet: broadcast \u2014 conversations.open returned no channel for user ${e}`);return}let a=Ts(t);await this.slackApi("chat.postMessage",{channel:n,text:a})}catch(s){console.error(`Agent Fleet: broadcast failed on ${this.config.name}`,s)}}async setThreadTitle(t,e){let s=this.threadContext.get(t);if(s)try{await this.slackApi("assistant.threads.setTitle",{channel_id:s.channelId,thread_ts:s.threadTs,title:e})}catch(n){console.warn(`Agent Fleet: assistant.threads.setTitle failed on ${this.config.name}`,n)}}async setTyping(t,e){let s=this.threadContext.get(t);if(s)try{await this.slackApi("assistant.threads.setStatus",{channel_id:s.channelId,thread_ts:s.threadTs,status:e?"is thinking...":""})}catch(n){console.warn(`Agent Fleet: assistant.threads.setStatus (${e?"on":"off"}) failed on ${this.config.name}`,n)}}onInbound(t){return this.inboundHandlers.add(t),()=>this.inboundHandlers.delete(t)}onStatusChange(t){return this.statusHandlers.add(t),()=>this.statusHandlers.delete(t)}setAllowedAgentsResolver(t){this.allowedAgentsResolver=t}onAgentSwitch(t){return this.agentSwitchHandlers.add(t),()=>this.agentSwitchHandlers.delete(t)}async connect(){if(this.stopping)return;this.setStatus(this.ws?"reconnecting":"connecting");let t;try{let e=await this.slackApi("apps.connections.open",{},{useAppToken:!0});if(!e.ok||!e.url)throw new Error(e.error??"apps.connections.open returned no URL");t=e.url}catch(e){console.error(`Agent Fleet: Slack apps.connections.open failed for channel ${this.config.name}`,e),this.setStatus("needs-auth"),this.scheduleReconnect();return}try{let e=new as(t);e.on("open",()=>{}),e.on("message",a=>{this.handleSocketData(a)}),e.on("error",a=>{console.warn(`Agent Fleet: Slack WebSocket error on ${this.config.name}`,a),this.setStatus("error")}),e.on("close",()=>{this.ws=null,this.stopping||this.scheduleReconnect()}),this.ws=e;let s=window.setTimeout(()=>{if(this.status==="connecting"||this.status==="reconnecting"){console.warn(`Agent Fleet: Slack WebSocket connect timeout on ${this.config.name}`);try{e.close()}catch{}}},3e4);e.on("close",()=>window.clearTimeout(s));let n=this.onStatusChange(a=>{a==="connected"&&(window.clearTimeout(s),n())})}catch(e){console.error("Agent Fleet: Slack WebSocket open failed",e),this.setStatus("error"),this.scheduleReconnect();return}}handleSocketData(t){let e;try{e=JSON.parse(t.toString())}catch{return}if(e.type==="hello"){this.backoffMs=1e3,this.setStatus("connected");return}if(e.type==="disconnect"){try{this.ws?.close()}catch{}return}if(e.type==="events_api"&&e.envelope_id){this.ackEnvelope(e.envelope_id),this.routeEventPayload(e.payload);return}if(e.type==="slash_commands"&&e.envelope_id){this.ackEnvelope(e.envelope_id),this.handleSlashCommand(e.payload);return}if(e.type==="interactive"&&e.envelope_id){this.ackEnvelope(e.envelope_id),this.handleInteraction(e.payload);return}e.envelope_id&&this.ackEnvelope(e.envelope_id)}async handleSlashCommand(t){if(!t)return;let e=t.command,s=t.channel_id,n=t.user_id;if(!(!e||!s||!n)){if(e==="/agents"){let a=this.allowedAgentsResolver?this.allowedAgentsResolver():this.config.allowedAgents;if(a.length===0){await this.slackApi("chat.postEphemeral",{channel:s,user:n,text:"No agents available. Add existing, enabled agents to `allowed_agents` in the channel file."});return}let i=a.map(c=>({type:"button",text:{type:"plain_text",text:c===this.config.defaultAgent?`${c} \u2713`:c,emoji:!0},action_id:`switch_agent_${c}`,value:c})),o=[{type:"section",text:{type:"mrkdwn",text:"*Select an agent to chat with:*"}}];for(let c=0;c\s*/,"").trim(),!n.text))return;let o=Jd(n),c=n.thread_ts??n.ts;if(n.channel&&c&&(this.threadContext.set(o,{channelId:n.channel,threadTs:c}),this.threadContext.size>500)){let d=this.threadContext.keys().next();d.done||this.threadContext.delete(d.value)}let l={conversationId:o,externalUserId:n.user,text:n.text,timestamp:new Date().toISOString(),meta:{slack_channel:n.channel,slack_ts:n.ts,thread_ts:n.thread_ts}};for(let h of this.inboundHandlers)try{h(l)}catch(d){console.error("Agent Fleet: Slack inbound handler threw",d)}}scheduleReconnect(){if(this.stopping||this.reconnectTimer)return;let t=this.backoffMs;this.backoffMs=Math.min(3e4,this.backoffMs*2),console.warn(`Agent Fleet: Slack channel ${this.config.name} scheduling reconnect in ${t}ms`),this.reconnectTimer=window.setTimeout(()=>{this.reconnectTimer=null,!this.stopping&&this.connect()},t)}setStatus(t){if(this.status!==t){this.status=t;for(let e of this.statusHandlers)try{e(t)}catch{}}}async slackApi(t,e,s={}){let n=s.useAppToken?this.credential.appToken:this.credential.botToken,a=`${Kd}/${t}`,i=await(0,Io.requestUrl)({url:a,method:"POST",contentType:"application/json; charset=utf-8",headers:{Authorization:`Bearer ${n}`},body:JSON.stringify(e),throw:!1});if(i.status===429){let c=Number(i.headers["retry-after"]??"1");return await new Promise(l=>window.setTimeout(l,Math.max(1e3,c*1e3))),this.slackApi(t,e,s)}if(i.status<200||i.status>=300)throw new Error(`Slack ${t} HTTP ${i.status}`);let o=i.json;if(o.ok===!1)throw new Error(`Slack ${t} error: ${o.error??"unknown"}`);return o}async enqueueSend(t,e){let n=(this.sendQueues.get(t)??Promise.resolve()).then(async()=>{try{await e()}finally{await new Promise(i=>window.setTimeout(i,1e3))}}),a=n.catch(i=>{console.warn(`Agent Fleet: Slack send queue error for ${t}`,i)});this.sendQueues.set(t,a),await n,this.sendQueues.get(t)===a&&this.sendQueues.delete(t)}};var Ga=require("obsidian"),Mo="https://api.telegram.org",Nn=class{type="telegram";config;credential;status="stopped";stopping=!1;pollOffset=0;pollTimer=null;backoffMs=1e3;typingIntervals=new Map;pollAbort=null;inboundHandlers=new Set;statusHandlers=new Set;agentSwitchHandlers=new Set;allowedAgentsResolver;constructor(t,e){if(e.type!=="telegram")throw new Error(`TelegramAdapter requires a telegram credential, got ${e.type}`);this.config=t,this.credential=e}async start(){this.stopping=!1;try{let e=(await this.tgApi("getUpdates",{offset:-1,limit:1})).result?.[0];e&&(this.pollOffset=e.update_id+1)}catch{}try{await this.tgApi("setMyCommands",{commands:[{command:"agents",description:"List available agents"}]})}catch{}this.setStatus("connected"),this.poll()}async stop(){this.stopping=!0,this.pollTimer&&(window.clearTimeout(this.pollTimer),this.pollTimer=null),this.pollAbort?.abort(),this.pollAbort=null;for(let[,t]of this.typingIntervals)window.clearInterval(t);this.typingIntervals.clear(),this.setStatus("stopped")}getStatus(){return this.status}async send(t,e){let s=Lo(t);if(!s)return;let n=Fo(t),a=za(e,4096);for(let i of a)await this.tgApi("sendMessage",{chat_id:s,text:i,parse_mode:"Markdown",...n?{message_thread_id:Number(n)}:{}})}async sendToTarget(t,e){if(!t)return;let s=za(e,4096);for(let n of s)await this.tgApi("sendMessage",{chat_id:t,text:n,parse_mode:"Markdown"})}async setTyping(t,e){let s=Lo(t);if(!s)return;let n=Fo(t),a={chat_id:s,action:"typing"};if(n&&(a.message_thread_id=Number(n)),e){let i=this.typingIntervals.get(t);i&&window.clearInterval(i);try{await this.tgApi("sendChatAction",a)}catch(c){console.warn("Agent Fleet: Telegram sendChatAction failed",c)}let o=window.setInterval(()=>{this.tgApi("sendChatAction",a).catch(()=>{})},4500);this.typingIntervals.set(t,o)}else{let i=this.typingIntervals.get(t);i&&(window.clearInterval(i),this.typingIntervals.delete(t))}}async setThreadTitle(t,e){}async broadcast(t){let e=this.config.allowedUsers[0];if(e)try{let s=za(t,4096);for(let n of s)await this.tgApi("sendMessage",{chat_id:e,text:n,parse_mode:"Markdown"})}catch(s){console.error(`Agent Fleet: Telegram broadcast failed on ${this.config.name}`,s)}}onInbound(t){return this.inboundHandlers.add(t),()=>this.inboundHandlers.delete(t)}onStatusChange(t){return this.statusHandlers.add(t),()=>this.statusHandlers.delete(t)}setAllowedAgentsResolver(t){this.allowedAgentsResolver=t}pickerAgents(){return this.allowedAgentsResolver?this.allowedAgentsResolver():this.config.allowedAgents}onAgentSwitch(t){return this.agentSwitchHandlers.add(t),()=>this.agentSwitchHandlers.delete(t)}poll(){this.stopping||(async()=>{try{this.pollAbort=new AbortController;let t=await this.tgApi("getUpdates",{offset:this.pollOffset,timeout:30,allowed_updates:["message","callback_query"]},this.pollAbort.signal);if(t.ok&&t.result)for(let e of t.result){if(this.pollOffset=e.update_id+1,e.callback_query){this.handleCallbackQuery(e.callback_query);continue}e.message&&this.routeMessage(e.message)}this.backoffMs=1e3,this.status!=="connected"&&this.setStatus("connected")}catch(t){if(t instanceof DOMException&&t.name==="AbortError")return;if(console.warn(`Agent Fleet: Telegram poll failed on ${this.config.name}`,t),this.status!=="error"&&this.status!=="needs-auth"){let e=t instanceof Error?t.message:String(t);this.setStatus(e.includes("401")||e.includes("Unauthorized")?"needs-auth":"error")}await new Promise(e=>window.setTimeout(e,this.backoffMs)),this.backoffMs=Math.min(3e4,this.backoffMs*2)}this.stopping||(this.pollTimer=window.setTimeout(()=>this.poll(),100))})()}routeMessage(t){let e=t.photo&&t.photo.length>0,s=t.document&&this.isImageMime(t.document.mime_type),n=!!t.text;if(!t.from||!n&&!e&&!s)return;let a=t.text??t.caption??"";if(a==="/agents"||a.startsWith("/agents@")){this.handleAgentsCommand(t);return}if(a.startsWith("/"))return;let i=String(t.from.id),o=String(t.chat.id),c=t.message_thread_id?String(t.message_thread_id):void 0,l=c?`tg:${o}:topic:${c}`:`tg:${o}`;this.buildAndEmitMessage(t,a,l,i,o,c)}async buildAndEmitMessage(t,e,s,n,a,i){let o=[];try{if(t.photo&&t.photo.length>0){let l=t.photo[t.photo.length-1],h=await this.downloadFile(l.file_id,`photo_${t.message_id}.jpg`,"image/jpeg");h&&o.push(h)}if(t.document&&this.isImageMime(t.document.mime_type)){let l=t.document.file_name??`doc_${t.message_id}`,h=t.document.mime_type??"image/jpeg",d=await this.downloadFile(t.document.file_id,l,h);d&&o.push(d)}}catch(l){console.warn("Agent Fleet: Telegram image download failed",l)}let c={conversationId:s,externalUserId:n,text:e,timestamp:new Date(t.date*1e3).toISOString(),meta:{telegram_chat_id:a,telegram_message_id:t.message_id,telegram_thread_id:i,chat_type:t.chat.type},...o.length>0?{images:o}:{}};for(let l of this.inboundHandlers)try{l(c)}catch(h){console.error("Agent Fleet: Telegram inbound handler threw",h)}}async downloadFile(t,e,s){let a=(await this.tgApi("getFile",{file_id:t})).result?.file_path;if(!a)return null;let i=`${Mo}/file/bot${this.credential.botToken}/${a}`;return{data:(await(0,Ga.requestUrl)({url:i,method:"GET"})).arrayBuffer,filename:e,mimeType:s}}isImageMime(t){return t?t==="image/jpeg"||t==="image/png"||t==="image/gif"||t==="image/webp"||t==="image/bmp"||t==="image/tiff":!1}async handleAgentsCommand(t){let e=String(t.chat.id),s=t.message_thread_id,n=this.pickerAgents();if(n.length===0){await this.tgApi("sendMessage",{chat_id:e,text:"No agents available. Add existing, enabled agents to `allowed_agents` in the channel file.",...s?{message_thread_id:s}:{}});return}let a=n.map(i=>[{text:i===this.config.defaultAgent?`${i} \u2713`:i,callback_data:`switch:${i}`}]);await this.tgApi("sendMessage",{chat_id:e,text:"*Select an agent to chat with:*",parse_mode:"Markdown",reply_markup:{inline_keyboard:a},...s?{message_thread_id:s}:{}})}async handleCallbackQuery(t){let e=t.data;if(!e?.startsWith("switch:")){await this.tgApi("answerCallbackQuery",{callback_query_id:t.id});return}let s=e.slice(7),n=String(t.from.id),a=String(t.message?.chat?.id??t.from.id),i=t.message?.message_thread_id?String(t.message.message_thread_id):void 0,o=i?`tg:${a}:topic:${i}`:`tg:${a}`;for(let c of this.agentSwitchHandlers)try{c(o,s,n)}catch(l){console.error("Agent Fleet: Telegram agent switch handler threw",l)}if(await this.tgApi("answerCallbackQuery",{callback_query_id:t.id,text:`Switched to ${s}`}),t.message){let l=this.pickerAgents().map(h=>[{text:h===s?`${h} \u2713`:h,callback_data:`switch:${h}`}]);try{await this.tgApi("editMessageText",{chat_id:a,message_id:t.message.message_id,text:`*Active agent: ${s}*`,parse_mode:"Markdown",reply_markup:{inline_keyboard:l}})}catch{}}}async tgApi(t,e,s){if(s?.aborted)throw new DOMException("Aborted","AbortError");let n=`${Mo}/bot${this.credential.botToken}/${t}`,a=(0,Ga.requestUrl)({url:n,method:"POST",contentType:"application/json",body:JSON.stringify(e),throw:!1}),i;if(s?i=await Promise.race([a,new Promise((o,c)=>{s.addEventListener("abort",()=>c(new DOMException("Aborted","AbortError")),{once:!0})})]):i=await a,i.status===401||i.status===403)throw new Error(`Telegram ${t} ${i.status} Unauthorized`);if(i.status===429){let c=i.json?.parameters?.retry_after??1;return await new Promise(l=>window.setTimeout(l,c*1e3)),this.tgApi(t,e)}if(i.status<200||i.status>=300)throw new Error(`Telegram ${t} HTTP ${i.status}: ${i.text}`);return i.json}setStatus(t){if(this.status!==t){this.status=t;for(let e of this.statusHandlers)try{e(t)}catch{}}}};function Lo(r){return r.startsWith("tg:")?r.split(":")[1]??null:null}function Fo(r){let t=r.split(":");if(t[2]==="topic"&&t[3])return t[3]}function za(r,t){if(r.length<=t)return[r];let e=[],s=r;for(;s.length>t;){let n=s.lastIndexOf(` - -`,t);n{for(let n of s)await this.discordApi("POST",`/channels/${t}/messages`,{content:n})})}async setTyping(t,e){let s=Uo(t);if(s)if(e){let n=this.typingIntervals.get(t);n&&window.clearInterval(n);try{await this.discordApi("POST",`/channels/${s}/typing`)}catch(i){console.warn("Agent Fleet: Discord typing trigger failed",i)}let a=window.setInterval(()=>{this.discordApi("POST",`/channels/${s}/typing`).catch(()=>{})},8e3);this.typingIntervals.set(t,a)}else{let n=this.typingIntervals.get(t);n&&(window.clearInterval(n),this.typingIntervals.delete(t))}}async broadcast(t){let e=this.config.allowedUsers[0];if(e)try{let n=(await this.discordApi("POST","/users/@me/channels",{recipient_id:e}))?.id;if(!n)return;let a=$o(t,2e3);for(let i of a)await this.discordApi("POST",`/channels/${n}/messages`,{content:i})}catch(s){console.error(`Agent Fleet: Discord broadcast failed on ${this.config.name}`,s)}}onInbound(t){return this.inboundHandlers.add(t),()=>this.inboundHandlers.delete(t)}onStatusChange(t){return this.statusHandlers.add(t),()=>this.statusHandlers.delete(t)}onAgentSwitch(t){return this.agentSwitchHandlers.add(t),()=>this.agentSwitchHandlers.delete(t)}setAllowedAgentsResolver(t){this.allowedAgentsResolver=t}pickerAgents(){return this.allowedAgentsResolver?this.allowedAgentsResolver():this.config.allowedAgents}async connect(){if(this.stopping)return;this.setStatus(this.ws?"reconnecting":"connecting");let e=`${this.canResume&&this.resumeGatewayUrl?this.resumeGatewayUrl:eh}${th}`;try{let s=new as(e);s.on("message",i=>{this.handleFrame(i)}),s.on("error",i=>{console.warn(`Agent Fleet: Discord WebSocket error on ${this.config.name}`,i)}),s.on("close",i=>{this.ws=null,this.clearHeartbeat(),this.handleClose(i)}),this.ws=s;let n=window.setTimeout(()=>{if(this.status==="connecting"||this.status==="reconnecting"){console.warn(`Agent Fleet: Discord WebSocket connect timeout on ${this.config.name}`);try{s.close()}catch{}}},3e4);s.on("close",()=>window.clearTimeout(n));let a=this.onStatusChange(i=>{i==="connected"&&(window.clearTimeout(n),a())})}catch(s){console.error("Agent Fleet: Discord WebSocket open failed",s),this.setStatus("error"),this.scheduleReconnect()}}handleClose(t){let e=!1;t===4014?(console.error(`Agent Fleet: Discord channel ${this.config.name} \u2014 disallowed intents (4014). Enable the Message Content intent in the Developer Portal \u2192 your app \u2192 Bot \u2192 Privileged Gateway Intents, then reload.`),this.canResume=!1,this.setStatus("needs-auth"),e=!0):t===4004||t===4013?(console.error(`Agent Fleet: Discord channel ${this.config.name} \u2014 gateway auth/intents error (${t}). Check the bot token credential, then reload.`),this.canResume=!1,this.setStatus("needs-auth"),e=!0):(t===4007||t===4009)&&(this.canResume=!1),!this.stopping&&!e&&this.scheduleReconnect()}handleFrame(t){let e;try{e=JSON.parse(t.toString())}catch{return}switch(typeof e.s=="number"&&(this.seq=e.s),e.op){case ch:{let s=e.d?.heartbeat_interval;this.startHeartbeat(s??41250),this.canResume&&this.sessionId&&this.seq!==null?this.sendGateway(ih,{token:this.credential.botToken,session_id:this.sessionId,seq:this.seq}):this.identify();return}case Va:{this.sendGateway(Va,this.seq);return}case dh:{this.heartbeatAcked=!0;return}case oh:{try{this.ws?.close()}catch{}return}case lh:{e.d===!0||(this.canResume=!1,this.sessionId=null),window.setTimeout(()=>{try{this.ws?.close()}catch{}},1e3+Math.floor(Math.random()*4e3));return}case ah:{this.handleDispatch(e.t??"",e.d);return}default:return}}handleDispatch(t,e){if(t==="READY"){let s=e;this.sessionId=s.session_id??null,this.resumeGatewayUrl=s.resume_gateway_url??null,this.selfUserId=s.user?.id??null,this.applicationId=s.application?.id??null,this.canResume=!0,this.backoffMs=1e3,this.setStatus("connected"),this.registerAgentsCommand();return}if(t==="RESUMED"){this.backoffMs=1e3,this.setStatus("connected");return}if(t==="MESSAGE_CREATE"){this.routeMessage(e);return}if(t==="INTERACTION_CREATE"){this.handleInteraction(e);return}}identify(){this.sendGateway(rh,{token:this.credential.botToken,intents:nh,properties:{os:"linux",browser:"agent-fleet",device:"agent-fleet"}})}sendGateway(t,e){if(!(!this.ws||this.ws.readyState!==as.OPEN))try{this.ws.send(JSON.stringify({op:t,d:e}))}catch(s){console.warn("Agent Fleet: Discord gateway send failed",s)}}startHeartbeat(t){this.clearHeartbeat(),this.heartbeatAcked=!0;let e=()=>{if(!this.heartbeatAcked){console.warn(`Agent Fleet: Discord heartbeat not ACKed on ${this.config.name} \u2014 reconnecting`);try{this.ws?.close()}catch{}return}this.heartbeatAcked=!1,this.sendGateway(Va,this.seq)};this.heartbeatInitialTimer=window.setTimeout(()=>{e(),this.heartbeatTimer=window.setInterval(e,t)},Math.floor(t*Math.random()))}clearHeartbeat(){this.heartbeatInitialTimer&&(window.clearTimeout(this.heartbeatInitialTimer),this.heartbeatInitialTimer=null),this.heartbeatTimer&&(window.clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}scheduleReconnect(){if(this.stopping||this.reconnectTimer)return;let t=this.backoffMs;this.backoffMs=Math.min(3e4,this.backoffMs*2),console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${t}ms`),this.reconnectTimer=window.setTimeout(()=>{this.reconnectTimer=null,!this.stopping&&this.connect()},t)}routeMessage(t){if(!t.author||t.author.bot||this.selfUserId&&t.author.id===this.selfUserId||!t.channel_id)return;let e=(t.attachments??[]).filter(a=>typeof a.content_type=="string"&&a.content_type.startsWith("image/")),s=mh(t.content??"",this.selfUserId);if(!s&&e.length===0){this.warnedEmptyContent||(this.warnedEmptyContent=!0,console.warn(`Agent Fleet: Discord channel ${this.config.name} received a message with empty content. If this persists, enable the Message Content intent in the Developer Portal.`));return}let n=Bo(t.guild_id,t.channel_id);this.buildAndEmitMessage(t,s,n,e)}async buildAndEmitMessage(t,e,s,n){let a=[];for(let o of n)try{let c=await(0,Ya.requestUrl)({url:o.url,method:"GET"});a.push({data:c.arrayBuffer,filename:o.filename||`attachment_${t.id}`,mimeType:o.content_type??"image/jpeg"})}catch(c){console.warn("Agent Fleet: Discord attachment download failed",c)}let i={conversationId:s,externalUserId:t.author.id,text:e,timestamp:t.timestamp??new Date().toISOString(),meta:{discord_guild_id:t.guild_id,discord_channel_id:t.channel_id,discord_message_id:t.id,is_dm:!t.guild_id},...a.length>0?{images:a}:{}};for(let o of this.inboundHandlers)try{o(i)}catch(c){console.error("Agent Fleet: Discord inbound handler threw",c)}}async registerAgentsCommand(){if(!(this.commandRegistered||!this.applicationId))try{await this.discordApi("PUT",`/applications/${this.applicationId}/commands`,[{name:"agents",description:"Switch the active agent",type:1}]),this.commandRegistered=!0}catch(t){console.warn(`Agent Fleet: Discord /agents command registration failed on ${this.config.name}`,t)}}async handleInteraction(t){if(t.type===hh){t.data?.name==="agents"&&await this.respondWithAgentPicker(t);return}if(t.type===uh){let e=t.data?.custom_id??"";if(!e.startsWith("switch:"))return;let s=e.slice(7),n=t.member?.user?.id??t.user?.id,a=t.channel_id;if(!n||!a)return;let i=Bo(t.guild_id,a);for(let o of this.agentSwitchHandlers)try{o(i,s,n)}catch(c){console.error("Agent Fleet: Discord agent switch handler threw",c)}await this.respondToInteraction(t,ph,{content:`Active agent: **${s}**`,components:this.buildAgentButtons(s)});return}}async respondWithAgentPicker(t){if(this.pickerAgents().length===0){await this.respondToInteraction(t,Oo,{content:"No agents available. Add existing, enabled agents to `allowed_agents` in the channel file.",flags:No});return}await this.respondToInteraction(t,Oo,{content:"Select an agent to chat with:",flags:No,components:this.buildAgentButtons(this.config.defaultAgent)})}buildAgentButtons(t){let e=this.pickerAgents().slice(0,25),s=[];for(let n=0;n({type:2,style:i===t?1:2,label:i===t?`${i} \u2713`:i,custom_id:`switch:${i}`}))})}return s}async respondToInteraction(t,e,s){try{await this.discordApi("POST",`/interactions/${t.id}/${t.token}/callback`,{type:e,data:s})}catch(n){console.warn("Agent Fleet: Discord interaction response failed",n)}}async discordApi(t,e,s){let n=await(0,Ya.requestUrl)({url:`${Zd}${e}`,method:t,contentType:"application/json",headers:{Authorization:`Bot ${this.credential.botToken}`,"User-Agent":sh},...s!==void 0?{body:JSON.stringify(s)}:{},throw:!1});if(n.status===429){let a=n.json?.retry_after??Number(n.headers["retry-after"]??"1");return await new Promise(i=>window.setTimeout(i,Math.max(1e3,a*1e3))),this.discordApi(t,e,s)}if(n.status===401||n.status===403)throw new Error(`Discord ${t} ${e} ${n.status}: ${fh(n.text)}`);if(n.status<200||n.status>=300)throw new Error(`Discord ${t} ${e} HTTP ${n.status}: ${n.text}`);if(n.status!==204)return n.json}async enqueueSend(t,e){let n=(this.sendQueues.get(t)??Promise.resolve()).then(e),a=n.catch(()=>{});this.sendQueues.set(t,a);try{await n}finally{this.sendQueues.get(t)===a&&this.sendQueues.delete(t)}}setStatus(t){if(this.status!==t){this.status=t;for(let e of this.statusHandlers)try{e(t)}catch{}}}};function Bo(r,t){return r?`discord:${r}:${t}`:`discord:dm:${t}`}function Uo(r){let t=r.split(":");return t[0]!=="discord"?null:t[2]??null}function mh(r,t){let e=r.trimStart();if(t){let s=new RegExp(`^<@!?${t}>\\s*`);e=e.replace(s,"")}return e.trim()}function fh(r){let t=r??"";try{let e=JSON.parse(t);if(e&&(e.message!==void 0||e.code!==void 0))return`${e.message??"error"} (code ${e.code??"?"})`}catch{}return t||"no body"}function $o(r,t){if(r.length<=t)return[r];let e=[],s=r;for(;s.length>t;){let n=s.lastIndexOf(` - -`,t);ns.agents.length},{icon:"columns-3",label:"Tasks Board",page:"kanban"},{icon:"scroll-text",label:"Run History",page:"runs"},{icon:"shield-check",label:"Approvals",page:"approvals",badge:()=>n.pending},{icon:"puzzle",label:"Skills",page:"skills",badge:()=>s.skills.length},{icon:"plug",label:"MCP Servers",page:"mcp",badge:()=>this.plugin.repository.getMcpServers().length},{icon:"radio",label:"Channels",page:"channels",badge:()=>this.plugin.channelManager?.getConnectedCount()??s.channels.length}];for(let l of i){let h=a.createDiv({cls:"af-sidebar-nav-item"}),d=h.createSpan({cls:"af-sidebar-nav-icon"});(0,Ms.setIcon)(d,l.icon),h.createSpan({cls:"af-sidebar-nav-label",text:l.label});let u=l.badge?.();u!==void 0&&u>0&&h.createSpan({cls:"af-sidebar-badge",text:String(u)}),h.setAttribute("role","button"),h.setAttribute("tabindex","0"),h.onclick=()=>void this.plugin.navigateDashboard(l.page),h.onkeydown=p=>{(p.key==="Enter"||p.key===" ")&&(p.preventDefault(),this.plugin.navigateDashboard(l.page))}}let o=e.createDiv({cls:"af-sidebar-section"});o.createDiv({cls:"af-sidebar-section-header",text:"AGENTS"}),s.agents.length===0&&o.createDiv({cls:"af-sidebar-empty",text:"No agents configured"});for(let l of s.agents){let h=this.plugin.runtime.getAgentState(l.name),d=o.createDiv({cls:"af-sidebar-agent-item"});d.createSpan({cls:`af-sidebar-agent-dot ${this.healthToClass(h.status)}`}),d.createSpan({cls:"af-sidebar-agent-name",text:l.name}),d.setAttribute("role","button"),d.setAttribute("tabindex","0"),d.onclick=()=>void this.plugin.navigateDashboard("agent-detail",l.name),d.onkeydown=u=>{(u.key==="Enter"||u.key===" ")&&(u.preventDefault(),this.plugin.navigateDashboard("agent-detail",l.name))}}let c=e.createDiv({cls:"af-sidebar-section"});c.createDiv({cls:"af-sidebar-section-header",text:"QUICK ACTIONS"}),this.renderQuickAction(c,"plus","New Agent",()=>void this.plugin.createAgentTemplate()),this.renderQuickAction(c,"plus","New Task",()=>void this.plugin.openCreateTask()),this.renderQuickAction(c,"plus","New Skill",()=>void this.plugin.createSkillTemplate())}renderQuickAction(e,s,n,a){let i=e.createDiv({cls:"af-sidebar-action-item"}),o=i.createSpan({cls:"af-sidebar-action-icon"});(0,Ms.setIcon)(o,s),i.createSpan({text:n}),i.setAttribute("role","button"),i.setAttribute("tabindex","0"),i.onclick=a,i.onkeydown=c=>{(c.key==="Enter"||c.key===" ")&&(c.preventDefault(),a())}}healthToClass(e){switch(e){case"running":return"running";case"error":return"error";case"pending":return"pending";case"disabled":return"disabled";default:return"idle"}}};var b=require("obsidian");var rs=require("obsidian"),gh=["bot","brain","shield-check","search","file-text","rocket","wand","sparkles","zap","target","compass","eye","code","terminal","database","globe","mail","message-circle","book","pen-tool","palette","music","camera","chart-bar","clipboard","cpu","server","cloud","lock","key","bell","calendar","clock","heart","star","flag","bookmark"],$n=class extends rs.Modal{constructor(e,s,n){super(e);this.onSelect=n;this.selectedIcon=s}searchQuery="";selectedIcon;allIcons=[];gridContainer;onOpen(){this.allIcons=(0,rs.getIconIds)().sort();let{contentEl:e}=this;e.empty(),e.addClass("af-icon-picker-modal");let s=e.createEl("input",{cls:"af-icon-picker-search",attr:{type:"text",placeholder:"Search icons...",value:this.searchQuery}});this.gridContainer=e.createDiv({cls:"af-icon-picker-scroll"}),this.renderGrid(),s.addEventListener("input",()=>{this.searchQuery=s.value,this.renderGrid()}),window.setTimeout(()=>s.focus(),0)}renderGrid(){let e=this.gridContainer;e.empty();let s=this.searchQuery.toLowerCase().trim();if(!s)this.renderSection(e,"Popular",gh),this.renderSection(e,"All Icons",this.allIcons);else{let n=this.allIcons.filter(i=>i.includes(s)),a=n.length===0?"No results":`${n.length} result${n.length!==1?"s":""}`;this.renderSection(e,a,n)}}renderSection(e,s,n){let a=e.createDiv({cls:"af-icon-picker-section"});a.createDiv({cls:"af-icon-picker-section-label",text:s});let i=a.createDiv({cls:"af-icon-picker-grid"});for(let o of n)this.renderIconItem(i,o)}renderIconItem(e,s){let n=e.createDiv({cls:`af-icon-picker-item${this.selectedIcon===s?" selected":""}`});n.setAttribute("title",s),n.setAttribute("aria-label",s),(0,rs.setIcon)(n,s),n.addEventListener("click",()=>{this.selectedIcon=s,this.onSelect(s),this.close()})}onClose(){this.contentEl.empty()}};var yh=[{match:/opus/i,rates:{input:15,output:75,cacheWrite:18.75,cacheRead:1.5}},{match:/sonnet/i,rates:{input:3,output:15,cacheWrite:3.75,cacheRead:.3}},{match:/haiku/i,rates:{input:1,output:5,cacheWrite:1.25,cacheRead:.1}},{match:/gpt-5|codex|o[0-9]/i,rates:{input:1.25,output:10,cacheWrite:1.25,cacheRead:.125}}],vh={input:3,output:15,cacheWrite:3.75,cacheRead:.3};function jo(r){for(let{match:t,rates:e}of yh)if(t.test(r))return e;return vh}function Wo(r,t){let e=jo(r);return(t.inputTokens*e.input+t.outputTokens*e.output+t.cacheCreateTokens*e.cacheWrite+t.cacheReadTokens*e.cacheRead)/1e6}function Ho(r,t){let e=jo(r),s=.7*e.input+.3*e.output;return t*s/1e6}var qo=require("obsidian");function R(r,t,e){let s=r.createSpan({cls:e??"af-icon"});return(0,qo.setIcon)(s,t),s}function Ze(r,t={}){let e=activeDocument.createElementNS("http://www.w3.org/2000/svg",r);for(let[s,n]of Object.entries(t))e.setAttribute(s,n);return e}function wh(r){try{return new Date(r+"T12:00:00").toLocaleDateString(void 0,{month:"short",day:"numeric"})}catch{return r.slice(5)}}function zo(r,t){let e=t.length||1,s=1e3,n=6,a=4,i=4,o=s-a-i-n*(e-1),c=Math.floor(o/e),l=140,h=36,d=18,u=d+l+h,p=Math.max(1,...t.map(f=>f.success+f.failure+f.cancelled)),m=Ze("svg",{viewBox:`0 0 ${s} ${u}`,width:"100%",height:String(u),class:"af-chart-bar"});for(let f=0;f<=4;f++){let g=d+l-f/4*l;m.appendChild(Ze("line",{x1:String(a),y1:String(g),x2:String(s-i),y2:String(g),stroke:"var(--af-text-faint)","stroke-opacity":"0.15","stroke-width":"1"}))}for(let f=0;f0&&m.appendChild(Ze("rect",{x:String(y),y:String(d+l-w),width:String(c),height:String(Math.max(w,2)),fill:"var(--af-green)",opacity:"0.85"})),g.cancelled>0&&m.appendChild(Ze("rect",{x:String(y),y:String(d+l-w-S),width:String(c),height:String(Math.max(S,2)),fill:"var(--af-yellow)",opacity:"0.85"})),g.failure>0&&m.appendChild(Ze("rect",{x:String(y),y:String(d+l-k),width:String(c),height:String(Math.max(T,2)),fill:"var(--af-red)",opacity:"0.85"})),v===0&&m.appendChild(Ze("rect",{x:String(y),y:String(d+l-3),width:String(c),height:"3",rx:"1.5",fill:"var(--af-text-faint)",opacity:"0.2"})),v>0){let D=Ze("text",{x:String(y+c/2),y:String(d+l-k-5),"text-anchor":"middle","font-size":"10","font-weight":"600",fill:"var(--af-text-secondary)"});D.textContent=String(v),m.appendChild(D)}let _=Ze("text",{x:String(y+c/2),y:String(d+l+16),"text-anchor":"middle","font-size":"10",fill:"var(--af-text-muted)"});_.textContent=wh(g.date),m.appendChild(_)}r.appendChild(m)}function Go(r,t,e){let c=2*Math.PI*46,l=e>0?t/e:0,h=c*l,d=c-h,u=Ze("svg",{viewBox:"0 0 130 130",width:String(130),height:String(130),class:"af-chart-donut"});u.appendChild(Ze("circle",{cx:String(65),cy:String(65),r:String(46),fill:"none",stroke:e>0?"var(--af-red)":"var(--af-text-faint)","stroke-width":String(12),opacity:"0.2"})),l>0&&u.appendChild(Ze("circle",{cx:String(65),cy:String(65),r:String(46),fill:"none",stroke:"var(--af-green)","stroke-width":String(12),"stroke-dasharray":`${h} ${d}`,"stroke-dashoffset":String(c*.25),"stroke-linecap":"round"}));let p=Ze("text",{x:String(65),y:String(61),"text-anchor":"middle","dominant-baseline":"middle","font-size":"24","font-weight":"700",fill:"var(--af-text-primary)"});p.textContent=`${Math.round(l*100)}%`,u.appendChild(p);let m=Ze("text",{x:String(65),y:String(83),"text-anchor":"middle","font-size":"10",fill:"var(--af-text-muted)"});m.textContent=`${t}/${e} runs`,u.appendChild(m),r.appendChild(u)}function Vo(r,t){r.draggable=!0,r.addEventListener("dragstart",e=>{e.dataTransfer?.setData("text/plain",t),r.addClass("af-dragging")}),r.addEventListener("dragend",()=>{r.removeClass("af-dragging")})}function Yo(r,t){r.addEventListener("dragover",e=>{e.preventDefault(),r.addClass("af-drag-over")}),r.addEventListener("dragleave",e=>{let s=e.relatedTarget;(!s||!r.contains(s))&&r.removeClass("af-drag-over")}),r.addEventListener("drop",e=>{e.preventDefault();let s=e.dataTransfer?.getData("text/plain");r.removeClass("af-drag-over"),s&&t(s)})}function Ko(r){let t=/^##\s+Lint\s+(\d{4}-\d{2}-\d{2})\s*$/gm,e,s=-1,n="";for(;(e=t.exec(r))!==null;)e.index>s&&(s=e.index,n=e[1]??"");if(s<0)return null;let a=r.slice(s),i=a.search(/\n##\s+(?!\s*#)/),o=i<0?a:a.slice(0,i);return{date:n,summary:jn(o,"Summary"),autoApplied:jn(o,"Auto-applied"),needsReview:jn(o,"Needs review"),refreshChained:jn(o,"Refresh chained")}}function jn(r,t){let e=t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),n=new RegExp(`^###\\s+${e}\\s*$`,"m").exec(r);if(!n)return[];let a=n.index+n[0].length,i=r.slice(a),o=i.search(/\n###\s+/),l=(o<0?i:i.slice(0,o)).split(/\r?\n/).map(d=>d.trimEnd()),h=[];for(let d of l){let u=d.replace(/^\s+/,"");u.startsWith("- ")?h.push(u.slice(2).trim()):h.length>0&&(d.startsWith(" ")||d.startsWith(" "))&&(h[h.length-1]+=" "+u)}return h}var Jo={dashboard:"Dashboard",agents:"Agents",kanban:"Tasks Board",runs:"Run History",skills:"Skills Library",approvals:"Approvals",mcp:"MCP Servers",channels:"Channels","wiki-keepers":"Wiki Keepers","agent-detail":"Agent Details","task-detail":"Task Details","create-agent":"Create Agent","create-task":"Create Task","create-skill":"Create Skill","edit-agent":"Edit Agent","edit-task":"Edit Task","edit-skill":"Edit Skill","create-channel":"Create Channel","edit-channel":"Edit Channel","add-mcp-server":"Add MCP Server"},bh={dashboard:"layout-dashboard",agents:"bot",kanban:"columns-3",runs:"scroll-text",skills:"puzzle",approvals:"shield-check",mcp:"plug",channels:"radio","wiki-keepers":"library","agent-detail":"bot","task-detail":"circle-dot","create-agent":"plus","create-task":"plus","create-skill":"plus","edit-agent":"edit","edit-task":"edit","edit-skill":"edit","create-channel":"plus","edit-channel":"edit","add-mcp-server":"plus"},kh=["dashboard","agents","kanban","runs","approvals","skills","wiki-keepers","mcp","channels"],Xo=[["claude-code","Claude Code",!1],["codex","Codex",!1],["process","Process (coming soon)",!0],["http","HTTP (coming soon)",!0]],Zo=[["bypassPermissions","Bypass Permissions","Auto-approve everything except deny list"],["dontAsk","Don\u2019t Ask","Auto-approve all tool calls"],["acceptEdits","Accept Edits","Auto-approve file edits, block bash unless allowed"],["plan","Plan","Read-only mode, no writes or commands"],["default","Default","Ask for each tool call"]],el=[["bypassPermissions","Bypass (no sandbox)","No sandbox, auto-approve everything"],["workspace-write","Workspace Write","Sandboxed: writes only inside the working dir"],["read-only","Read Only","Sandboxed: no writes or side-effect commands"]];function Ls(r){let t=r.trim().toLowerCase();return t==="codex"||t==="openai-codex"}function Wn(r){return Ls(r)?el:Zo}function Hn(r,t){if(Ls(t))switch(r){case"acceptEdits":case"default":return"workspace-write";case"plan":return"read-only";case"dontAsk":return"bypassPermissions";default:return el.some(([e])=>e===r)?r:"bypassPermissions"}switch(r){case"workspace-write":return"acceptEdits";case"read-only":return"plan";case"danger-full-access":return"bypassPermissions";default:return Zo.some(([e])=>e===r)?r:"bypassPermissions"}}var Fs=class extends b.ItemView{constructor(e,s){super(e);this.plugin=s}currentPage="dashboard";detailContext;agentDetailTab="overview";streamingUnsubscribes=[];channelStatusUnsubscribe;authenticatingServers=new Set;getViewType(){return _t}getDisplayText(){return"Agent Fleet"}getIcon(){return"bot"}async onOpen(){this.plugin.subscribeView(this),this.channelStatusUnsubscribe=this.plugin.channelManager?.onStatusChange(()=>{this.currentPage==="channels"&&this.render()}),await this.render()}async onClose(){this.cleanupStreaming(),this.channelStatusUnsubscribe?.(),this.channelStatusUnsubscribe=void 0,this.plugin.unsubscribeView(this)}navigateTo(e,s){this.currentPage=e,this.detailContext=s,e!=="agent-detail"&&(this.agentDetailTab="overview"),this.render()}async render(){this.cleanupStreaming();let e=this.contentEl;e.empty(),e.addClass("af-root");let n=e.createDiv({cls:"af-app"}).createDiv({cls:"af-main-content"});this.renderTopBar(n),this.renderTabBar(n);let a=n.createDiv({cls:"af-page"});switch(this.currentPage){case"dashboard":this.renderDashboardPage(a);break;case"agents":this.renderAgentsPage(a);break;case"kanban":this.renderKanbanPage(a);break;case"runs":this.renderRunsPage(a);break;case"skills":this.renderSkillsPage(a);break;case"approvals":this.renderApprovalsPage(a);break;case"mcp":this.renderMcpPage(a);break;case"channels":this.renderChannelsPage(a);break;case"wiki-keepers":this.renderWikiKeepersPage(a);break;case"agent-detail":this.renderAgentDetailPage(a);break;case"task-detail":this.renderTaskDetailPage(a);break;case"create-agent":this.renderCreateAgentPage(a);break;case"create-task":this.renderCreateTaskPage(a);break;case"create-skill":this.renderCreateSkillPage(a);break;case"edit-agent":this.renderEditAgentPage(a);break;case"edit-task":this.renderEditTaskPage(a);break;case"edit-skill":this.renderEditSkillPage(a);break;case"create-channel":this.renderCreateChannelPage(a);break;case"edit-channel":this.renderEditChannelPage(a);break;case"add-mcp-server":this.renderAddMcpServerPage(a);break}}navigate(e,s){this.navigateTo(e,s)}cleanupStreaming(){for(let e of this.streamingUnsubscribes)e();this.streamingUnsubscribes=[]}renderTopBar(e){let s=e.createDiv({cls:"af-top-bar"}),n=s.createDiv({cls:"af-top-bar-title"});R(n,"bot","af-top-bar-icon"),n.createSpan({text:"Agent Fleet"});let a=s.createDiv({cls:"af-breadcrumb"}),i=a.createSpan({cls:"af-breadcrumb-sep"});(0,b.setIcon)(i,"chevron-right");let o=(m,f,g)=>{let y=a.createSpan({cls:f?"af-breadcrumb-link":"",text:m});f&&(y.onclick=()=>this.navigate(f,g))},c=()=>{let m=a.createSpan({cls:"af-breadcrumb-sep"});(0,b.setIcon)(m,"chevron-right")};switch(this.currentPage){case"agent-detail":o("Agents","agents"),c(),o(this.detailContext??"Agent");break;case"task-detail":o("Tasks Board","kanban"),c(),o(this.detailContext??"Task");break;case"edit-agent":o("Agents","agents"),c(),o(this.detailContext??"Agent","agent-detail",this.detailContext),c(),o("Edit");break;case"edit-task":o("Tasks Board","kanban"),c(),o(this.detailContext??"Task","task-detail",this.detailContext),c(),o("Edit");break;case"create-agent":o("Agents","agents"),c(),o("New Agent");break;case"create-task":o("Tasks Board","kanban"),c(),o("New Task");break;case"create-skill":o("Skills Library","skills"),c(),o("New Skill");break;case"edit-skill":o("Skills Library","skills"),c(),o(this.detailContext??"Skill"),c(),o("Edit");break;case"create-channel":o("Channels","channels"),c(),o("New Channel");break;case"edit-channel":o("Channels","channels"),c(),o(this.detailContext??"Channel"),c(),o("Edit");break;case"add-mcp-server":o("MCP Servers","mcp"),c(),o("Add Server");break;default:o(Jo[this.currentPage])}s.createDiv({cls:"af-top-bar-spacer"});let l=s.createDiv({cls:"af-search-wrap"});R(l,"search","af-search-icon");let h=l.createEl("input",{cls:"af-search-input",attr:{type:"text",placeholder:"Search agents, tasks, runs..."}});h.addEventListener("input",()=>{this.handleSearch(h.value,l)}),h.addEventListener("blur",()=>{window.setTimeout(()=>l.querySelector(".af-search-results")?.remove(),200)});let d=this.plugin.runtime.getFleetStatus(),u=s.createDiv({cls:"af-status-pills"});if(d.running>0){let m=u.createSpan({cls:"af-pill yellow"});m.createSpan({cls:"af-dot pulse"}),m.appendText(` ${d.running} Running`)}if(d.pending>0){let m=u.createSpan({cls:"af-pill blue"});m.createSpan({cls:"af-dot"}),m.appendText(` ${d.pending} Pending`)}let p=u.createSpan({cls:"af-pill green"});p.createSpan({cls:"af-dot"}),p.appendText(` ${d.completedToday} Today`)}renderTabBar(e){let s=e.createDiv({cls:"af-tab-bar"}),n=this.plugin.runtime.getSnapshot(),a=this.plugin.runtime.getFleetStatus();for(let i of kh){let o=this.currentPage===i||i==="agents"&&(this.currentPage==="agent-detail"||this.currentPage==="edit-agent"||this.currentPage==="create-agent")||i==="kanban"&&(this.currentPage==="task-detail"||this.currentPage==="edit-task")||i==="skills"&&(this.currentPage==="edit-skill"||this.currentPage==="create-skill")||i==="mcp"&&this.currentPage==="add-mcp-server",c=s.createEl("button",{cls:`af-tab-item${o?" active":""}`}),l=c.createSpan({cls:"af-tab-icon"});if((0,b.setIcon)(l,bh[i]),c.appendText(i==="dashboard"?"Overview":Jo[i]),i==="agents")c.createSpan({cls:"af-badge",text:String(n.agents.length)});else if(i==="skills")c.createSpan({cls:"af-badge",text:String(n.skills.length)});else if(i==="mcp"){let h=this.plugin.repository.getMcpServers().length;c.createSpan({cls:"af-badge",text:String(h)})}else i==="approvals"&&a.pending>0&&c.createSpan({cls:"af-badge af-badge-warn",text:String(a.pending)});c.onclick=()=>this.navigate(i)}}renderDashboardPage(e){let s=e.createDiv({cls:"af-dashboard"}),n=this.plugin.runtime.getSnapshot(),a=this.plugin.runtime.getRecentRuns(),i=this.plugin.runtime.getFleetStatus(),o=a.filter(S=>(S.approvals??[]).some(T=>T.status==="pending"));for(let S of o)for(let T of S.approvals??[])T.status==="pending"&&this.renderApprovalBanner(s,S,T.tool);let c=s.createDiv({cls:"af-dash-grid"}),l=n.agents.filter(S=>S.enabled).length,h=n.agents.length;this.renderStatCard(c,"Active Agents",`${l}`,`/ ${h}`,"bot",`${l} of ${h} enabled`);let d=this.toLocalDateStr(new Date),u=a.filter(S=>this.runToLocalDate(S.started)===d),p=u.filter(S=>S.status==="success").length,m=u.filter(S=>S.status==="failure"||S.status==="timeout").length;this.renderStatCard(c,"Runs Today",String(u.length),"","activity",`${p} passed \xB7 ${m} failed \xB7 ${i.running} running`);let f=this.plugin.runtime.getUsageRecords().filter(S=>this.runToLocalDate(S.ts)===d),{tokens:g,cost:y}=this.combinedTotals(u,f),v=y>0?` \xB7 $${y.toFixed(2)}`:"";this.renderStatCard(c,"Tokens Used",Ka(g),"","zap",`today${v}`);let k=n.tasks.filter(S=>S.enabled&&S.schedule);this.renderStatCard(c,"Scheduled Tasks",String(k.length),"","clock",k.length>0?`Next: ${this.getNextTaskLabel(k)}`:"No scheduled tasks"),this.renderChartsRow(s,a,this.plugin.runtime.getChartRuns()),this.renderStreamingCards(s);let w=s.createDiv({cls:"af-dash-split"});this.renderActivityTimeline(w,a),this.renderFleetStatusPanel(w,n)}renderChartsRow(e,s,n){let a=e.createDiv({cls:"af-charts-row"}),i=a.createDiv({cls:"af-section-card af-chart-section"}),c=i.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(c,"activity"),c.appendText(" Run Activity (14d)");let l=i.createDiv({cls:"af-chart-body"}),h=this.buildChartData(n,14);h.some(y=>y.success+y.failure+y.cancelled>0)?zo(l,h):this.renderEmptyState(l,"activity","No run data","Run agents to see activity");let d=a.createDiv({cls:"af-section-card af-chart-section"}),p=d.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(p,"target"),p.appendText(" Success Rate");let m=d.createDiv({cls:"af-chart-body af-chart-body-center"}),f=s.length,g=s.filter(y=>y.status==="success").length;f>0?Go(m,g,f):this.renderEmptyState(m,"target","No data","")}toLocalDateStr(e){let s=n=>String(n).padStart(2,"0");return`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}`}runToLocalDate(e){return this.toLocalDateStr(new Date(e))}runCost(e){return e.costUsd??Ho(e.model,e.tokensUsed??0)}usageCost(e){return e.costUsd??Wo(e.model,{inputTokens:e.inputTokens,outputTokens:e.outputTokens,cacheReadTokens:e.cacheReadTokens,cacheCreateTokens:e.cacheCreateTokens})}combinedTotals(e,s){let n=e.reduce((i,o)=>i+(o.tokensUsed??0),0)+s.reduce((i,o)=>i+o.totalTokens,0),a=e.reduce((i,o)=>i+this.runCost(o),0)+s.reduce((i,o)=>i+this.usageCost(o),0);return{tokens:n,cost:a}}buildChartData(e,s){let n=[],a=new Date;for(let i=s-1;i>=0;i--){let o=new Date(a);o.setDate(o.getDate()-i);let c=this.toLocalDateStr(o),l=e.filter(h=>this.runToLocalDate(h.started)===c);n.push({date:c,success:l.filter(h=>h.status==="success").length,failure:l.filter(h=>h.status==="failure"||h.status==="timeout").length,cancelled:l.filter(h=>h.status==="cancelled").length})}return n}renderStreamingCards(e){let n=this.plugin.runtime.getSnapshot().agents.filter(c=>this.plugin.runtime.getAgentState(c.name).status==="running");if(n.length===0)return;let a=e.createDiv({cls:"af-streaming-section"}),o=a.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(o,"terminal"),o.appendText(" Active Agents");for(let c of n)this.renderStreamingCard(a,c.name)}renderStreamingCard(e,s){let n=e.createDiv({cls:"af-streaming-card"}),a=this.plugin.runtime.getAgentState(s),i=this.plugin.runtime.getSnapshot(),o=a.currentTaskId?i.tasks.find(m=>m.taskId===a.currentTaskId):void 0,c=o?` \u2192 ${o.taskId}`:a.currentTaskId?.startsWith("reflection-")?" \u2192 Reflection":a.currentTaskId?.startsWith("heartbeat-")||a.status==="running"?" \u2192 Heartbeat":"",l=n.createDiv({cls:"af-streaming-card-header"});l.createSpan({cls:"af-dot pulse",attr:{style:"background: var(--af-yellow)"}}),l.createSpan({cls:"af-streaming-card-agent",text:` ${s}`}),c&&l.createSpan({cls:"af-streaming-card-task",text:c});let h=n.createDiv({cls:"af-streaming-output"}),d=a.currentTaskId?.startsWith("reflection-")?"Consolidating memory\u2026":"Working\u2026",u=m=>{let f=ye(m).filter(g=>g.trim().length>0).slice(-4);h.setText(f.length>0?f.join(` -`):d)};u(this.plugin.runtime.getRunOutputBuffer(s));let p=this.plugin.runtime.onRunOutput(s,()=>{u(this.plugin.runtime.getRunOutputBuffer(s)),h.scrollTop=h.scrollHeight});this.streamingUnsubscribes.push(p)}renderApprovalBanner(e,s,n){let a=e.createDiv({cls:"af-approval-banner"}),i=a.createDiv({cls:"af-approval-icon"});(0,b.setIcon)(i,"shield-check");let o=a.createDiv({cls:"af-approval-text"});o.createDiv({cls:"af-approval-title",text:`${s.agent} wants to run: ${n}`}),o.createDiv({cls:"af-approval-desc",text:`Approval required for tool: ${n}`});let c=a.createDiv({cls:"af-approval-actions"}),l=c.createEl("button",{cls:"af-btn-approve",text:"Approve"});l.onclick=()=>void this.plugin.runtime.resolveApproval(s,n,"approved").then(()=>this.render());let h=c.createEl("button",{cls:"af-btn-reject",text:"Reject"});h.onclick=()=>void this.plugin.runtime.resolveApproval(s,n,"rejected").then(()=>this.render())}renderStatCard(e,s,n,a,i,o){let c=e.createDiv({cls:"af-stat-card"}),l=c.createDiv({cls:"af-stat-label"});R(l,i,"af-stat-icon"),l.appendText(` ${s}`);let h=c.createDiv({cls:"af-stat-value"});h.appendText(n),a&&h.createSpan({cls:"af-stat-value-suffix",text:a}),c.createDiv({cls:"af-stat-sub",text:o})}renderActivityTimeline(e,s){let n=e.createDiv({cls:"af-section-card"}),i=n.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(i,"inbox"),i.appendText(" Recent Activity");let o=n.createDiv({cls:"af-timeline"}),c=s.slice(0,8);if(c.length===0){this.renderEmptyState(o,"inbox","No runs yet","Run an agent to see activity here");return}for(let l of c)this.renderTimelineItem(o,l)}renderTimelineItem(e,s){let n=e.createDiv({cls:"af-timeline-item"}),a=this.statusToTimelineClass(s.status),i=n.createDiv({cls:`af-tl-icon ${a}`});(0,b.setIcon)(i,this.statusToIconName(s.status));let o=n.createDiv({cls:"af-tl-body"}),c=o.createDiv({cls:"af-tl-title"});c.createSpan({cls:"af-agent-tag",text:s.agent}),c.appendText(` ${s.task}`),o.createDiv({cls:"af-tl-desc",text:Wt(s.output,100)});let l=o.createDiv({cls:"af-tl-meta"}),h=l.createSpan();if(R(h,"clock","af-meta-icon"),h.appendText(` ${this.formatStarted(s.started)} \xB7 ${this.formatDuration(s.durationSeconds)}`),s.tokensUsed){let d=l.createSpan();R(d,"zap","af-meta-icon"),d.appendText(` ${s.tokensUsed.toLocaleString()} tokens`)}n.onclick=()=>this.openSlideover(s)}renderFleetStatusPanel(e,s){let n=e.createDiv({cls:"af-section-card"}),a=n.createDiv({cls:"af-section-header"}),i=a.createDiv({cls:"af-section-title"});R(i,"bot"),i.appendText(" Fleet Status");let c=a.createDiv({cls:"af-section-actions"}).createEl("button",{cls:"af-btn-sm primary"});R(c,"plus","af-btn-icon"),c.appendText(" New Agent"),c.onclick=()=>void this.plugin.createAgentTemplate();let l=n.createDiv({cls:"af-agent-mini-list"});if(s.agents.length===0){this.renderEmptyState(l,"bot","No agents yet","Create your first agent to get started");return}for(let d of s.agents)this.renderAgentMini(l,d,s.tasks);let h=s.agents.filter(d=>d.enabled);if(h.length>0){let d=n.createDiv({cls:"af-quick-run"}),u=d.createDiv({cls:"af-quick-run-label"});R(u,"zap","af-meta-icon"),u.appendText(" Quick Run");let p=d.createDiv({cls:"af-quick-run-row"}),m=p.createEl("select",{cls:"af-select"});for(let g of h)m.createEl("option",{text:g.name,attr:{value:g.name}});let f=p.createEl("button",{cls:"af-btn-sm primary"});R(f,"play","af-btn-icon"),f.appendText(" Run"),f.onclick=()=>void this.plugin.runAgentPrompt(m.value)}}renderAgentMini(e,s,n){let a=this.plugin.runtime.getAgentState(s.name),i=n.filter(u=>u.agent===s.name),o=this.healthToClass(a.status),c=e.createDiv({cls:"af-agent-mini"}),l=c.createDiv({cls:`af-agent-avatar ${o}`});s.avatar?.trim()?this.renderAgentAvatar(l,s):l.setText(this.getInitials(s.name));let h=c.createDiv({cls:"af-agent-info"});h.createDiv({cls:"af-agent-name",text:s.name});let d="";if(a.status==="running")d=`Running now \xB7 ${i.length} task${i.length!==1?"s":""}`;else if(!s.enabled)d=`Disabled \xB7 ${i.length} task${i.length!==1?"s":""} paused`;else{let u=i.map(p=>p.nextRun).filter(Boolean).sort()[0];d=u?`Next: ${this.formatNextRun(u)} \xB7 ${i.length} task${i.length!==1?"s":""}`:`${i.length} task${i.length!==1?"s":""}`}h.createDiv({cls:"af-agent-desc",text:d}),c.createDiv({cls:`af-agent-status-dot ${o}`}),c.onclick=()=>this.navigate("agent-detail",s.name)}renderAgentsPage(e){let s=e.createDiv({cls:"af-agents-page"}),n=this.plugin.runtime.getSnapshot(),a=s.createDiv({cls:"af-agents-toolbar"});a.createDiv({cls:"af-page-title",text:"Agents"}),a.createDiv({cls:"af-toolbar-spacer"});let i=a.createEl("button",{cls:"af-btn-sm primary"});R(i,"plus","af-btn-icon"),i.appendText(" New Agent"),i.onclick=()=>void this.plugin.createAgentTemplate();let o=s.createDiv({cls:"af-agents-grid"});if(n.agents.length===0){this.renderEmptyState(o,"bot","No agents configured","Create your first agent to get started");return}for(let c of n.agents)this.renderAgentCard(o,c,n)}renderAgentCard(e,s,n){let a=this.plugin.runtime.getAgentState(s.name),i=this.plugin.runtime.getRecentRuns().filter(D=>D.agent===s.name),o=e.createDiv({cls:`af-agent-card${s.enabled?"":" disabled"}`}),c=o.createDiv({cls:"af-agent-card-header"}),l=s.enabled?this.healthToClass(a.status):"disabled",h=c.createDiv({cls:`af-agent-card-avatar ${l}`});this.renderAgentAvatar(h,s);let d=c.createDiv({cls:"af-agent-card-titleblock"}),u=d.createDiv({cls:"af-agent-card-name"});if(u.appendText(s.name),s.heartbeatEnabled&&s.heartbeatSchedule){let D=u.createSpan({cls:"af-heartbeat-indicator"});(0,b.setIcon)(D,"heart-pulse"),D.title=`Heartbeat: ${s.heartbeatSchedule}`}d.createDiv({cls:"af-agent-card-desc",text:s.description??"No description"});let p=c.createDiv({cls:`af-agent-card-toggle${s.enabled?" on":""}`});p.onclick=D=>{D.stopPropagation(),this.plugin.toggleAgent(s.name,!s.enabled)};let m=o.createDiv({cls:"af-agent-card-stats"}),f=i.length,g=i.filter(D=>D.status==="success").length,y=f>0?Math.round(g/f*100):0,v=f>0?Math.round(i.reduce((D,O)=>D+O.durationSeconds,0)/f):0,k=i.reduce((D,O)=>D+(O.tokensUsed??0),0);if(this.renderAgentStat(m,String(f),"Runs"),this.renderAgentStat(m,`${y}%`,"Success"),this.renderAgentStat(m,`${v}s`,"Avg Time"),this.renderAgentStat(m,Ka(k),"Tokens"),s.skills.length>0){let D=o.createDiv({cls:"af-agent-card-skills"});for(let O of s.skills)D.createSpan({cls:"af-skill-tag",text:O})}let w=o.createDiv({cls:"af-agent-card-footer"}),S=[`Model: ${s.model}`];s.approvalRequired.length>0&&S.push(`Approval: ${s.approvalRequired.join(", ")}`),s.memory&&S.push("Memory: on"),s.enabled||S.unshift("Disabled"),w.createSpan({cls:"af-agent-card-meta",text:S.join(" \xB7 ")});let T=w.createDiv({cls:"af-agent-card-actions"});if(!s.enabled){let D=T.createEl("button",{cls:"af-btn-sm",text:"Enable"});D.onclick=O=>{O.stopPropagation(),this.plugin.toggleAgent(s.name,!0)}}let _=T.createEl("button",{cls:"af-btn-sm"});if(R(_,"edit","af-btn-icon"),_.appendText(" Edit"),_.onclick=D=>{D.stopPropagation(),this.navigate("edit-agent",s.name)},s.enabled){let D=T.createEl("button",{cls:"af-btn-sm primary"});R(D,"play","af-btn-icon"),D.appendText(" Run"),D.onclick=O=>{O.stopPropagation(),this.plugin.runAgentPrompt(s.name)}}o.onclick=()=>this.navigate("agent-detail",s.name)}renderAgentStat(e,s,n){let a=e.createDiv({cls:"af-agent-stat"});a.createSpan({cls:"af-agent-stat-value",text:s}),a.createSpan({cls:"af-agent-stat-label",text:n})}renderAgentDetailPage(e){let s=e.createDiv({cls:"af-agent-detail-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"bot","No agent selected","Select an agent from the list");return}let a=this.plugin.runtime.getSnapshot().agents.find(k=>k.name===n);if(!a){this.renderEmptyState(s,"bot","Agent not found",`Agent "${n}" was not found`);return}let i=this.plugin.runtime.getAgentState(a.name),o=this.plugin.runtime.getRecentRuns().filter(k=>k.agent===a.name),c=s.createDiv({cls:"af-detail-header"}),l=c.createDiv({cls:"af-detail-header-left"}),h=l.createDiv({cls:`af-agent-card-avatar ${this.healthToClass(i.status)}`});this.renderAgentAvatar(h,a);let d=l.createDiv();d.createDiv({cls:"af-detail-header-name",text:a.name}),d.createDiv({cls:"af-detail-header-desc",text:a.description??"No description"});let u=c.createDiv({cls:"af-detail-header-actions"}),p=u.createEl("button",{cls:"af-btn-sm primary"});if(R(p,"message-circle","af-btn-icon"),p.appendText(" Chat"),p.onclick=()=>this.openChatSlideover(a),a.enabled){let k=u.createEl("button",{cls:"af-btn-sm"});R(k,"play","af-btn-icon"),k.appendText(" Run Now"),k.onclick=()=>void this.plugin.runAgentPrompt(a.name);let w=u.createEl("button",{cls:"af-btn-sm"});R(w,"pause","af-btn-icon"),w.appendText(" Disable"),w.onclick=()=>void this.plugin.toggleAgent(a.name,!1)}else{let k=u.createEl("button",{cls:"af-btn-sm"});R(k,"play","af-btn-icon"),k.appendText(" Enable"),k.onclick=()=>void this.plugin.toggleAgent(a.name,!0)}let m=u.createEl("button",{cls:"af-btn-sm"});R(m,"edit","af-btn-icon"),m.appendText(" Edit"),m.onclick=()=>this.navigate("edit-agent",a.name);let f=u.createEl("button",{cls:"af-btn-sm danger"});R(f,"trash-2","af-btn-icon"),f.appendText(" Delete"),f.onclick=()=>void this.plugin.deleteAgent(a.name);let g=s.createDiv({cls:"af-detail-tabs"}),y=[{id:"overview",label:"Overview",icon:"layout-dashboard"},{id:"config",label:"Config",icon:"settings"},{id:"runs",label:"Runs",icon:"scroll-text"},{id:"memory",label:"Memory",icon:"file-text"}];for(let k of y){let w=g.createEl("button",{cls:`af-detail-tab${this.agentDetailTab===k.id?" active":""}`});R(w,k.icon,"af-tab-icon"),w.appendText(` ${k.label}`),w.onclick=()=>{this.agentDetailTab=k.id,this.render()}}let v=s.createDiv({cls:"af-detail-tab-content"});switch(this.agentDetailTab){case"overview":this.renderAgentOverviewTab(v,a,o);break;case"config":this.renderAgentConfigTab(v,a);break;case"runs":this.renderAgentRunsTab(v,o);break;case"memory":this.renderAgentMemoryTab(v,a);break}}renderAgentOverviewTab(e,s,n){let a=e.createDiv({cls:"af-dash-grid"}),i=n.length,o=n.filter(w=>w.status==="success").length,c=i>0?Math.round(o/i*100):0,l=i>0?Math.round(n.reduce((w,S)=>w+S.durationSeconds,0)/i):0,h=this.plugin.runtime.getUsageRecords().filter(w=>w.agent===s.name),{tokens:d,cost:u}=this.combinedTotals(n,h),p=u>0?` \xB7 $${u.toFixed(2)}`:"";if(this.renderStatCard(a,"Total Runs",String(i),"","activity","all time"),this.renderStatCard(a,"Success Rate",`${c}%`,"","check-circle-2",`${o}/${i}`),this.renderStatCard(a,"Avg Time",`${l}s`,"","clock","per run"),this.renderStatCard(a,"Total Tokens",Ka(d),"","zap",`all time${p}`),s.isFolder&&(s.heartbeatBody.trim()||s.heartbeatEnabled)){let w=e.createDiv({cls:"af-section-card"}),S=w.createDiv({cls:"af-section-header"}),T=S.createDiv({cls:"af-section-title"});R(T,"heart-pulse"),T.appendText(" Heartbeat");let D=S.createDiv({cls:"af-detail-header-actions"}).createDiv({cls:`af-agent-card-toggle${s.heartbeatEnabled?" on":""}`});D.onclick=async()=>{let E=D.hasClass("on");await this.plugin.repository.updateHeartbeat(s.name,{enabled:!E}),await this.plugin.refreshFromVault(),new b.Notice(`Heartbeat ${E?"paused":"enabled"} for ${s.name}`)};let O=w.createDiv({cls:"af-config-form"});this.renderConfigRow(O,"Schedule",xh(s.heartbeatSchedule));let C=this.plugin.runtime.getNextHeartbeat(s.name);C&&s.heartbeatEnabled&&this.renderConfigRow(O,"Next run",this.timeUntil(C)),s.heartbeatChannel&&this.renderConfigRow(O,"Channel",s.heartbeatChannel)}if(s.skills.length>0){let w=e.createDiv({cls:"af-section-card"}),T=w.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(T,"puzzle"),T.appendText(" Skills");let _=w.createDiv({cls:"af-detail-skills-list"});for(let D of s.skills)_.createSpan({cls:"af-skill-tag",text:D})}let m=s.mcpServers??[];if(m.length>0){let w=e.createDiv({cls:"af-section-card"}),T=w.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(T,"plug"),T.appendText(" MCP Servers");let _=w.createDiv({cls:"af-mcp-overview-list"}),D=this.plugin.repository.getMcpServers();for(let O of m){let C=D.find(M=>M.name===O),E=_.createDiv({cls:"af-mcp-overview-row"}),P=E.createSpan({cls:`af-mcp-status-dot ${C?C.enabled?C.status:"disabled":"disconnected"}`});P.title=C?C.enabled?C.status:"disabled":"unknown",E.createSpan({cls:"af-mcp-overview-name",text:O});let N=C?.toolDetails.length??C?.tools.length??0;N>0?E.createSpan({cls:"af-mcp-overview-tools",text:`${N} tools`}):C&&!C.enabled?E.createSpan({cls:"af-mcp-overview-tools af-muted",text:"disabled"}):C?.status==="needs-auth"&&E.createSpan({cls:"af-mcp-overview-tools af-muted",text:"needs auth"})}}if(s.permissionRules.allow.length>0||s.permissionRules.deny.length>0||s.permissionMode&&s.permissionMode!=="default"){let w=e.createDiv({cls:"af-section-card"}),T=w.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(T,"shield-check"),T.appendText(" Permissions");let _=w.createDiv({cls:"af-config-form"});this.renderConfigRow(_,"Mode",s.permissionMode||"default"),s.permissionRules.allow.length>0&&this.renderConfigRow(_,"Allowed",s.permissionRules.allow.join(", ")),s.permissionRules.deny.length>0&&this.renderConfigRow(_,"Denied",s.permissionRules.deny.join(", "))}let g=e.createDiv({cls:"af-section-card"}),v=g.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(v,"scroll-text"),v.appendText(" Recent Runs");let k=g.createDiv({cls:"af-timeline"});if(n.length===0)this.renderEmptyState(k,"scroll-text","No runs yet","");else for(let w of n.slice(0,5))this.renderTimelineItem(k,w)}renderAgentConfigTab(e,s){let n=e.createDiv({cls:"af-config-form"});this.renderConfigRow(n,"Name",s.name),this.renderConfigRow(n,"Description",s.description??""),this.renderConfigRow(n,"Model",s.model),this.renderConfigRow(n,"Timeout",`${s.timeout}s`),this.renderConfigRow(n,"Working Directory",s.cwd??"(vault root)"),this.renderConfigRow(n,"Permission Mode",s.permissionMode||"default"),this.renderConfigRow(n,"Approval Required",s.approvalRequired.join(", ")||"none"),s.permissionRules.allow.length>0&&this.renderConfigRow(n,"Allowed Commands",s.permissionRules.allow.join(", ")),s.permissionRules.deny.length>0&&this.renderConfigRow(n,"Blocked Commands",s.permissionRules.deny.join(", ")),this.renderConfigRow(n,"Memory",s.memory?"Enabled":"Disabled"),this.renderConfigRow(n,"Auto-compact",s.autoCompactThreshold&&s.autoCompactThreshold>0?`at ${s.autoCompactThreshold}% context`:"disabled"),s.wikiReferences&&s.wikiReferences.length>0&&this.renderConfigRow(n,"Wiki access",s.wikiReferences.map(c=>c.agent).join(", ")),this.renderConfigRow(n,"Tags",s.tags.join(", ")||"none");let a=n.createDiv({cls:"af-config-prompt-section"});if(a.createDiv({cls:"af-slideover-section-title",text:"SYSTEM PROMPT"}),a.createDiv({cls:"af-output-block",text:s.body||"(empty)"}),s.heartbeatBody.trim()){let c=n.createDiv({cls:"af-config-prompt-section"});c.createDiv({cls:"af-slideover-section-title",text:"HEARTBEAT INSTRUCTION"}),c.createDiv({cls:"af-output-block",text:s.heartbeatBody})}let o=n.createDiv({cls:"af-slideover-actions"}).createEl("button",{cls:"af-btn-sm primary"});R(o,"edit","af-btn-icon"),o.appendText(" Edit Agent"),o.onclick=()=>this.navigate("edit-agent",s.name)}renderConfigRow(e,s,n){let a=e.createDiv({cls:"af-detail-row"});a.createSpan({cls:"af-detail-label",text:s}),a.createSpan({cls:"af-detail-value af-mono",text:n})}renderAgentRunsTab(e,s){if(s.length===0){this.renderEmptyState(e,"scroll-text","No runs yet","Run this agent to see history");return}for(let n of s){let a=e.createDiv({cls:"af-run-list-item"}),i=a.createDiv({cls:`af-tl-icon ${this.statusToTimelineClass(n.status)}`});(0,b.setIcon)(i,this.statusToIconName(n.status));let o=a.createDiv({cls:"af-tl-body"}),c=o.createDiv({cls:"af-tl-title"});c.createSpan({text:n.task}),c.createSpan({cls:`af-status-badge ${this.statusToBadgeClass(n.status)}`,text:this.statusToBadgeText(n.status)});let l=o.createDiv({cls:"af-tl-meta"});l.createSpan({text:`${this.formatStarted(n.started)} \xB7 ${this.formatDuration(n.durationSeconds)}`}),n.tokensUsed&&l.createSpan({text:`${n.tokensUsed.toLocaleString()} tokens`}),o.createDiv({cls:"af-tl-desc",text:Wt(n.output,120)}),a.onclick=()=>this.openSlideover(n)}}async renderAgentMemoryTab(e,s){if(!s.memory){this.renderEmptyState(e,"file-text","Memory disabled","Enable memory in agent config");return}let n=await this.plugin.repository.readWorkingMemory(s.name),a=e.createDiv({cls:"af-form-help"}),i=n?.tokenEstimate??0,o=s.reflection.enabled?`reflection on (${s.reflection.schedule})`:"reflection off";a.setText(`~${i} / ${s.memoryTokenBudget} tokens \xB7 ${o}`),!n||n.sections.length===0?this.renderEmptyState(e,"file-text","No memories yet","Agent will learn from runs"):e.createDiv({cls:"af-output-block"}).setText(Ke(n.sections));let c=e.createDiv({cls:"af-slideover-actions"}),l=c.createEl("button",{cls:"af-btn-sm"});R(l,"moon","af-btn-icon"),l.appendText(" Reflect now"),l.onclick=async()=>{l.disabled=!0,l.setText(" Reflecting\u2026");let d=await this.plugin.runtime.runReflectionNow(s.name);new b.Notice(`Agent Fleet: ${d.message}`),await this.renderAgentMemoryTab((e.empty(),e),s)};let h=c.createEl("button",{cls:"af-btn-sm"});R(h,"external-link","af-btn-icon"),h.appendText(" Open in Editor"),h.onclick=()=>void this.plugin.openPath(this.plugin.repository.getWorkingMemoryPath(s.name))}timeAgo(e){let s=Math.round((Date.now()-e.getTime())/1e3);if(s<60)return"just now";let n=Math.round(s/60);if(n<60)return`${n}m ago`;let a=Math.round(n/60);return a<24?`${a}h ago`:`${Math.round(a/24)}d ago`}timeUntil(e){let s=Math.round((e.getTime()-Date.now())/1e3);if(s<60)return"< 1m";let n=Math.round(s/60);if(n<60)return`in ${n}m`;let a=Math.round(n/60);return a<24?`in ${a}h`:`in ${Math.round(a/24)}d`}renderKanbanPage(e){let s=e.createDiv({cls:"af-kanban-page"}),n=this.plugin.runtime.getSnapshot(),a=this.plugin.runtime.getRecentRuns(),i=s.createDiv({cls:"af-kanban-toolbar"});i.createDiv({cls:"af-page-title",text:"Tasks Board"}),i.createDiv({cls:"af-toolbar-spacer"});let o=i.createEl("button",{cls:"af-btn-sm primary"});R(o,"plus","af-btn-icon"),o.appendText(" New Task"),o.onclick=()=>this.navigate("create-task");let c=s.createDiv({cls:"af-kanban-board"}),l=[],h=[],d=[],u=[],p=[],m=new Set;for(let v of n.agents){let k=this.plugin.runtime.getAgentState(v.name);k.status==="running"&&k.currentTaskId&&m.add(k.currentTaskId)}let f=this.toLocalDateStr(new Date);for(let v of a)v.status==="success"&&this.runToLocalDate(v.started)===f?u.push(v):(v.status==="failure"||v.status==="timeout"||v.status==="cancelled")&&this.runToLocalDate(v.started)===f&&p.push(v);let g=new Set(u.map(v=>v.task)),y=new Set(p.map(v=>v.task));for(let v of n.tasks){let k=g.has(v.taskId)||y.has(v.taskId)||v.lastRun&&this.runToLocalDate(v.lastRun)===f;if(m.has(v.taskId))d.push(v);else{if(k&&!v.schedule)continue;v.schedule&&v.enabled?h.push(v):l.push(v)}}this.renderKanbanColumn(c,"Backlog","inbox",l.length,v=>{for(let k of l)this.renderKanbanTaskCard(v,k,n,!0)},"backlog"),this.renderKanbanColumn(c,"Scheduled","clock",h.length,v=>{for(let k of h)this.renderKanbanTaskCard(v,k,n,!0)},"scheduled"),this.renderKanbanColumn(c,"Running","loader-2",d.length,v=>{for(let k of d)this.renderKanbanRunningCard(v,k)},"running",!1,"running"),this.renderKanbanColumn(c,"Done","check-circle-2",u.length,v=>{for(let k of u.slice(0,10))this.renderKanbanCompletedCard(v,k)},"completed"),this.renderKanbanColumn(c,"Failed","x-circle",p.length,v=>{for(let k of p)this.renderKanbanFailedCard(v,k)},"failed",!1,"failed")}renderKanbanColumn(e,s,n,a,i,o,c=!1,l){let h=e.createDiv({cls:`af-kanban-column${l?` af-kanban-${l}`:""}`});(o==="backlog"||o==="scheduled")&&Yo(h,m=>this.handleTaskDrop(m,o));let u=h.createDiv({cls:"af-kanban-col-header"}).createDiv({cls:"af-kanban-col-title"});R(u,n),u.appendText(` ${s} `),u.createSpan({cls:"af-kanban-col-count",text:String(a)});let p=h.createDiv({cls:"af-kanban-col-body"});if(i(p),a===0&&p.createDiv({cls:"af-kanban-empty",text:"No items"}),c){let f=h.createDiv({cls:"af-kanban-col-add"}).createEl("button");R(f,"plus","af-btn-icon"),f.appendText(" Add task"),f.onclick=()=>{this.navigate("create-task")}}}handleTaskDrop(e,s){let n=this.plugin.runtime.getSnapshot().tasks.find(a=>a.taskId===e);if(n){if(s==="backlog")this.setTaskEnabled(n,!1).then(()=>{new b.Notice(`Task "${e}" moved to backlog (disabled)`)});else if(s==="scheduled"){if(!n.schedule&&!n.runAt){new b.Notice(`Task "${e}" needs a schedule. Open task details to set one.`),this.navigate("task-detail",e);return}this.setTaskEnabled(n,!0).then(()=>{new b.Notice(`Task "${e}" moved to scheduled (enabled)`)})}}}async setTaskEnabled(e,s){let n=this.plugin.app.vault.getAbstractFileByPath(e.filePath);if(!n||!(n instanceof b.TFile))return;let a=await this.plugin.app.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);i.enabled=s,await this.plugin.app.vault.modify(n,H(i,o)),await this.plugin.refreshFromVault()}renderKanbanTaskCard(e,s,n,a){let i=e.createDiv({cls:`af-kanban-card af-priority-${s.priority}`});if(a){Vo(i,s.taskId);let f=i.createDiv({cls:"af-kanban-card-grip"});(0,b.setIcon)(f,"grip-vertical")}let o=i.createDiv({cls:"af-kanban-card-header"});o.createDiv({cls:"af-kanban-card-title",text:s.taskId});let l=(n.agents.find(f=>f.name===s.agent)?.enabled??!1)&&s.enabled,h=o.createSpan({cls:`af-kanban-card-status ${l?"active":"inactive"}`});h.title=l?"Active":"Inactive";let d=i.createDiv({cls:"af-kanban-card-agent"}),u=d.createSpan({cls:"af-kanban-card-agent-icon"});(0,b.setIcon)(u,"bot"),d.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let m=i.createDiv({cls:"af-kanban-card-footer"}).createSpan({cls:"af-kanban-card-schedule"});l?s.schedule?(R(m,"refresh-cw","af-meta-icon"),m.appendText(` ${this.humanizeCron(s.schedule)}`)):m.appendText(s.runAt??"Manual"):(R(m,"pause","af-meta-icon"),m.appendText(" Paused")),i.onclick=()=>this.navigate("task-detail",s.taskId)}renderKanbanRunningCard(e,s){let n=e.createDiv({cls:"af-kanban-card af-kanban-card-running"});n.createDiv({cls:"af-kanban-card-title",text:s.taskId});let a=n.createDiv({cls:"af-kanban-card-agent"}),i=a.createSpan({cls:"af-kanban-card-agent-icon"});(0,b.setIcon)(i,"bot"),a.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let c=this.plugin.runtime.getSnapshot().agents.find(S=>S.name===s.agent)?.timeout??300,l=this.plugin.runtime.getAgentState(s.agent),h=l.runStarted?new Date(l.runStarted).getTime():Date.now(),p=n.createDiv({cls:"af-kanban-progress"}).createDiv({cls:"af-kanban-progress-track"}).createDiv({cls:"af-kanban-progress-bar af-kanban-progress-bar-real"}),m=(Date.now()-h)/1e3,f=Math.min(95,m/c*100);p.setCssStyles({width:`${f}%`});let g=n.createDiv({cls:"af-kanban-card-footer"}),y=g.createSpan({cls:"af-kanban-card-schedule"});R(y,"loader-2","af-meta-icon");let v=Math.round(m);y.appendText(` ${v}s / ${c}s`);let k=g.createEl("button",{cls:"af-kanban-stop-btn"});(0,b.setIcon)(k,"square"),k.title="Stop task",k.onclick=S=>{S.stopPropagation(),this.plugin.runtime.abortAgentRun(s.agent),new b.Notice(`Stopped task "${s.taskId}"`)};let w=window.setInterval(()=>{let S=(Date.now()-h)/1e3,T=Math.min(95,S/c*100);p.setCssStyles({width:`${T}%`});let _=Math.round(S);y.textContent="",(0,b.setIcon)(y,"loader-2"),y.appendText(` ${_}s / ${c}s`)},1e3);this.streamingUnsubscribes.push(()=>window.clearInterval(w))}renderKanbanCompletedCard(e,s){let n=e.createDiv({cls:"af-kanban-card"});n.createDiv({cls:"af-kanban-card-title",text:s.task});let a=n.createDiv({cls:"af-kanban-card-agent"}),i=a.createSpan({cls:"af-kanban-card-agent-icon"});(0,b.setIcon)(i,"bot"),a.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let c=n.createDiv({cls:"af-kanban-card-footer"}).createSpan({cls:"af-kanban-card-schedule"});R(c,"check-circle-2","af-meta-icon"),c.appendText(` ${this.formatStarted(s.started)} \xB7 ${this.formatDuration(s.durationSeconds)}`),n.onclick=()=>this.openSlideover(s)}renderKanbanFailedCard(e,s){let n=s.status==="cancelled",a=e.createDiv({cls:`af-kanban-card ${n?"af-kanban-card-cancelled":"af-kanban-card-failed"}`});a.createDiv({cls:"af-kanban-card-title",text:s.task});let i=a.createDiv({cls:"af-kanban-card-agent"}),o=i.createSpan({cls:"af-kanban-card-agent-icon"});(0,b.setIcon)(o,"bot"),i.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let c=n?`Stopped after ${s.durationSeconds}s`:s.status==="timeout"?`Timeout after ${s.durationSeconds}s`:Wt(s.output,60),l=a.createDiv({cls:"af-kanban-card-error"});R(l,n?"square":"alert-triangle","af-meta-icon"),l.appendText(` ${c}`);let h=a.createDiv({cls:"af-kanban-card-footer"}),d=h.createSpan({cls:"af-kanban-card-schedule"});if(R(d,n?"square":"x-circle","af-meta-icon"),d.appendText(` ${this.formatStarted(s.started)}`),!n){let u=h.createEl("button",{cls:"af-btn-sm"});R(u,"refresh-cw","af-btn-icon"),u.appendText(" Retry"),u.onclick=p=>{p.stopPropagation(),this.plugin.runAgentPrompt(s.agent)}}a.onclick=()=>this.openSlideover(s)}renderRunsPage(e){let s=e.createDiv({cls:"af-runs-page"}),n=this.plugin.runtime.getRecentRuns(),a=s.createDiv({cls:"af-runs-toolbar"});a.createDiv({cls:"af-page-title",text:"Run History"}),a.createDiv({cls:"af-toolbar-spacer"});let i=s.createDiv({cls:"af-runs-table"});if(n.length===0){this.renderEmptyState(i,"scroll-text","No runs yet","Run an agent to see history here");return}let o=i.createEl("table"),l=o.createEl("thead").createEl("tr");for(let d of["Status","Agent","Task","Started","Duration","Tokens","Model"])l.createEl("th",{text:d});let h=o.createEl("tbody");for(let d of n.slice(0,50))this.renderRunRow(h,d)}renderRunRow(e,s){let n=e.createEl("tr"),i=n.createEl("td").createSpan({cls:`af-status-badge ${this.statusToBadgeClass(s.status)}`}),o=i.createSpan();(0,b.setIcon)(o,this.statusToIconName(s.status)),i.appendText(` ${this.statusToBadgeText(s.status)}`);let c=n.createEl("td",{cls:"af-agent-link"});c.setText(s.agent),c.onclick=l=>{l.stopPropagation(),this.navigate("agent-detail",s.agent)},n.createEl("td",{text:s.task}),n.createEl("td",{cls:"af-mono",text:this.formatStarted(s.started)}),n.createEl("td",{cls:"af-mono",text:this.formatDuration(s.durationSeconds)}),n.createEl("td",{cls:"af-mono",text:s.tokensUsed?s.tokensUsed.toLocaleString():"\u2014"}),n.createEl("td",{cls:"af-mono",text:s.model}),n.setCssStyles({cursor:"pointer"}),n.onclick=()=>this.openSlideover(s)}renderSkillsPage(e){let s=e.createDiv({cls:"af-skills-page"}),n=this.plugin.runtime.getSnapshot(),a=s.createDiv({cls:"af-agents-toolbar"});a.createDiv({cls:"af-page-title",text:"Skills Library"}),a.createDiv({cls:"af-toolbar-spacer"});let i=a.createEl("button",{cls:"af-btn-sm primary"});R(i,"plus","af-btn-icon"),i.appendText(" New Skill"),i.onclick=()=>void this.plugin.createSkillTemplate();let o=s.createDiv({cls:"af-skills-grid"});if(n.skills.length===0){this.renderEmptyState(o,"puzzle","No skills yet","Create skills to give agents specialized abilities");return}for(let c of n.skills)this.renderSkillCard(o,c,n.agents)}renderSkillCard(e,s,n){let a=e.createDiv({cls:"af-skill-card"}),i=a.createDiv({cls:"af-skill-card-header"}),o=i.createDiv({cls:"af-skill-card-icon"});(0,b.setIcon)(o,this.getSkillIcon(s.name));let c=i.createEl("button",{cls:"af-btn-sm af-btn-xs"});R(c,"edit","af-btn-icon"),c.onclick=h=>{h.stopPropagation(),this.navigate("edit-skill",s.name)},a.createDiv({cls:"af-skill-card-name",text:s.name}),a.createDiv({cls:"af-skill-card-desc",text:s.description??"No description"});let l=n.filter(h=>h.skills.includes(s.name));if(l.length>0){let h=a.createDiv({cls:"af-skill-card-agents"});for(let d of l)h.createSpan({cls:"af-skill-card-agent-tag",text:d.name})}}renderChannelsPage(e){let s=e.createDiv({cls:"af-agents-page"}),n=this.plugin.runtime.getSnapshot(),a=s.createDiv({cls:"af-agents-toolbar"});a.createDiv({cls:"af-page-title",text:"Channels"}),a.createDiv({cls:"af-toolbar-spacer"});let i=a.createEl("button",{cls:"af-btn-sm primary"});R(i,"plus","af-btn-icon"),i.appendText(" New Channel"),i.onclick=()=>this.navigate("create-channel");let o=s.createDiv({cls:"af-agents-grid"});if(n.channels.length===0){this.renderEmptyState(o,"radio","No channels configured","Connect an agent to Slack or another chat platform");return}for(let c of n.channels)this.renderChannelCard(o,c,n.validationIssues)}async renderWikiKeepersPage(e){let s=e.createDiv({cls:"af-agents-page"}),n=this.plugin.runtime.getSnapshot(),a=s.createDiv({cls:"af-agents-toolbar"});a.createDiv({cls:"af-page-title",text:"Wiki Keepers"}),a.createDiv({cls:"af-toolbar-spacer"});let i=n.agents.filter(c=>c.wikiKeeper!==void 0);if(i.length===0){this.renderEmptyState(s,"library","No Wiki Keepers yet","Open Settings \u2192 Agent Fleet \u2192 Wiki Keepers \u2192 + Add to create one.");return}let o=s.createDiv({cls:"af-wk-list"});o.setCssStyles({display:"flex"}),o.setCssStyles({flexDirection:"column"}),o.setCssStyles({gap:"16px"});for(let c of i)await this.renderWikiKeeperCard(o,c)}async renderWikiKeeperCard(e,s){let n=s.wikiKeeper,a=e.createDiv({cls:"af-card"});a.setCssStyles({padding:"16px"}),a.setCssStyles({border:"1px solid var(--background-modifier-border)"}),a.setCssStyles({borderRadius:"8px"});let i=a.createDiv();i.setCssStyles({display:"flex"}),i.setCssStyles({alignItems:"center"}),i.setCssStyles({gap:"12px"}),i.setCssStyles({marginBottom:"12px"});let o=i.createDiv();o.setCssStyles({flex:"1"}),o.createEl("strong",{text:s.name});let c=n.scopeRoot||"(whole vault)";o.createEl("div",{text:`Scope: ${c} \xB7 topics: ${n.topicsRoot}/ \xB7 log: ${n.logPath}`,cls:"af-form-hint"});let l=i.createEl("button",{cls:"af-btn-sm"});l.appendText("Open log"),l.onclick=()=>{let g=n.scopeRoot?`${n.scopeRoot}/${n.logPath}`:n.logPath,y=this.plugin.app.vault.getAbstractFileByPath(g);y instanceof b.TFile?this.plugin.app.workspace.getLeaf().openFile(y):new b.Notice(`Log file not found: ${g}`)};let h=n.scopeRoot?`${n.scopeRoot}/${n.logPath}`:n.logPath,d=this.plugin.app.vault.getAbstractFileByPath(h),u=null;if(d instanceof b.TFile)try{let g=await this.plugin.app.vault.cachedRead(d);u=Ko(g)}catch{u=null}if(!u){a.createDiv({cls:"af-form-hint"}).setText("No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.");return}let p=a.createDiv();if(p.setCssStyles({display:"flex"}),p.setCssStyles({alignItems:"baseline"}),p.setCssStyles({gap:"12px"}),p.setCssStyles({marginBottom:"8px"}),p.createEl("strong",{text:`Lint ${u.date}`}),p.createSpan({cls:"af-form-hint",text:`${u.summary.length} summary lines \xB7 ${u.autoApplied.length} auto-applied \xB7 ${u.needsReview.length} needs review`}),u.summary.length>0){let g=a.createEl("details");g.createEl("summary",{text:"Summary"});let y=g.createEl("ul");y.setCssStyles({marginTop:"4px"});for(let v of u.summary)y.createEl("li",{text:v})}if(u.autoApplied.length>0){let g=a.createEl("details");g.createEl("summary",{text:`Auto-applied (${u.autoApplied.length})`});let y=g.createEl("ul");y.setCssStyles({marginTop:"4px"});for(let v of u.autoApplied)y.createEl("li",{text:v})}if(u.refreshChained.length>0){let g=a.createEl("details");g.createEl("summary",{text:`Refresh chained (${u.refreshChained.length})`});let y=g.createEl("ul");y.setCssStyles({marginTop:"4px"});for(let v of u.refreshChained)y.createEl("li",{text:v})}let m=a.createDiv();if(m.setCssStyles({marginTop:"12px"}),m.createEl("strong",{text:`Needs review (${u.needsReview.length})`}),u.needsReview.length===0){m.createDiv({cls:"af-form-hint",text:"All clear. Nothing requires manual review from this lint pass."});return}let f=m.createDiv();f.setCssStyles({display:"flex"}),f.setCssStyles({flexDirection:"column"}),f.setCssStyles({gap:"6px"}),f.setCssStyles({marginTop:"8px"});for(let g of u.needsReview){let y=f.createDiv();y.setCssStyles({display:"flex"}),y.setCssStyles({alignItems:"flex-start"}),y.setCssStyles({gap:"8px"}),y.setCssStyles({padding:"8px 10px"}),y.setCssStyles({background:"var(--background-secondary)"}),y.setCssStyles({borderRadius:"4px"}),y.setCssStyles({fontSize:"13px"});let v=y.createDiv();v.setCssStyles({flex:"1"}),v.setText(g);let k=y.createEl("button",{cls:"af-btn-sm",text:"Dismiss"});k.title="Hide this item from the dashboard until the next lint pass (does not modify log.md).",k.onclick=()=>{y.remove()}}}renderChannelCard(e,s,n){let a=this.plugin.channelManager?.getChannelStatus(s.name)??"disabled",i=Qo(a),o=s.enabled&&a!=="disabled"?"af-agent-card":"af-agent-card disabled",c=e.createDiv({cls:o});c.setCssStyles({cursor:"default"});let l=c.createDiv({cls:"af-agent-card-header"}),h=l.createDiv({cls:`af-agent-card-avatar ${i}`});(0,b.setIcon)(h,"radio");let d=l.createDiv({cls:"af-agent-card-titleblock"});d.createDiv({cls:"af-agent-card-name",text:s.name}),d.createDiv({cls:"af-agent-card-desc",text:`Default: ${s.defaultAgent}`});let u=l.createSpan({cls:`af-pill ${Sh(a)}`});if(u.createSpan({cls:"af-dot"}),u.appendText(` ${a}`),s.allowedAgents.length>0){let T=c.createDiv({cls:"af-agent-card-skills"});for(let _ of s.allowedAgents){let D=T.createSpan({cls:"af-skill-tag",text:_});_===s.defaultAgent&&D.setCssStyles({fontWeight:"700"})}}let p=c.createDiv({cls:"af-agent-card-stats"}),m=this.plugin.channelManager?.getSessionCount(s.name)??0,f=this.plugin.channelManager?.getMetrics(s.name),g=s.allowedAgents.length>0?String(s.allowedAgents.length):"all";this.renderAgentStat(p,g,"Agents"),this.renderAgentStat(p,String(m),"Sessions"),this.renderAgentStat(p,String(f?.messagesReceived??0),"In"),this.renderAgentStat(p,String(f?.messagesSent??0),"Out");let y=c.createDiv({cls:"af-agent-card-footer"}),v=[s.type];s.enabled||v.push("disabled"),s.allowedUsers.length>0?v.push(`${s.allowedUsers.length} user(s)`):v.push("allowlist empty"),y.createSpan({cls:"af-agent-card-meta",text:v.join(" \xB7 ")});let w=y.createDiv({cls:"af-agent-card-actions"}).createEl("button",{cls:"af-btn-sm"});R(w,"edit","af-btn-icon"),w.appendText(" Edit"),w.onclick=T=>{T.stopPropagation(),this.navigate("edit-channel",s.name)};let S=n.filter(T=>T.path===s.filePath);if(S.length>0){let T=c.createDiv({cls:"af-channel-issues"});for(let _ of S)T.createDiv({cls:"af-channel-issue-row",text:_.message})}}renderCreateChannelPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=this.plugin.runtime.getSnapshot(),a=this.plugin.channelCredentials.list(),i=s.createDiv({cls:"af-detail-header"}),o=i.createDiv({cls:"af-detail-header-left"}),c=o.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(c,"plus");let l=o.createDiv();l.createDiv({cls:"af-detail-header-name",text:"Create New Channel"}),l.createDiv({cls:"af-detail-header-desc",text:"Connect an external chat transport to an agent"});let h=i.createDiv({cls:"af-detail-header-actions"}),d={name:"",type:"slack",defaultAgent:n.agents[0]?.name??"",allowedAgents:[],credentialRef:a[0]?.ref??"",allowedUsers:"",perUserSessions:!0,channelContext:"",enabled:!0,tags:"",body:"",transportJson:""},u=s.createDiv({cls:"af-create-form"}),p=u.createDiv({cls:"af-create-section"}),m=p.createDiv({cls:"af-create-section-header"}),f=m.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(f,"radio"),m.createSpan({text:"Channel Details"}),this.createFormField(p,"Name","my-slack","Unique identifier for this channel",j=>{d.name=j});let g=p.createDiv({cls:"af-form-row"});g.createDiv({cls:"af-form-label",text:"Type"});let y=g.createEl("select",{cls:"af-form-select"});y.createEl("option",{text:"slack",attr:{value:"slack"}}),y.createEl("option",{text:"telegram",attr:{value:"telegram"}}),y.createEl("option",{text:"discord",attr:{value:"discord"}}),y.addEventListener("change",()=>{d.type=y.value});let v=p.createDiv({cls:"af-form-row"}),k=v.createDiv({cls:"af-form-label"});k.setText("Credential"),this.addTooltip(k,"Configured in Settings \u2192 Agent Fleet \u2192 Channel Credentials");let w=v.createEl("select",{cls:"af-form-select"});a.length===0&&w.createEl("option",{text:"(no credentials configured)",attr:{value:""}});for(let j of a)w.createEl("option",{text:`${j.ref} (${j.entry.type})`,attr:{value:j.ref}});w.addEventListener("change",()=>{d.credentialRef=w.value});let S=p.createDiv({cls:"af-form-row af-form-row-toggle"});S.createDiv({cls:"af-form-label",text:"Enabled"});let T=S.createDiv({cls:"af-agent-card-toggle on"});T.onclick=()=>{let j=T.hasClass("on");T.toggleClass("on",!j),d.enabled=!j};let _=u.createDiv({cls:"af-create-section"}),D=_.createDiv({cls:"af-create-section-header"}),O=D.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(O,"bot"),D.createSpan({text:"Agent Routing"});let C=_.createDiv({cls:"af-form-row"}),E=C.createDiv({cls:"af-form-label"});E.setText("Default agent"),this.addTooltip(E,"Used when no @agent-name prefix is given in a message");let P=C.createEl("select",{cls:"af-form-select"});for(let j of n.agents)P.createEl("option",{text:j.name,attr:{value:j.name}});P.addEventListener("change",()=>{d.defaultAgent=P.value});let N=_.createDiv({cls:"af-form-row"}),M=N.createDiv({cls:"af-form-label"});M.setText("Allowed agents"),this.addTooltip(M,"Agents reachable via @prefix. Leave unchecked to allow all agents.");let U=N.createDiv({cls:"af-form-checkboxes"});for(let j of n.agents){let Ce=U.createEl("label",{cls:"af-form-checkbox-label"}),xe=Ce.createEl("input",{attr:{type:"checkbox",value:j.name}});Ce.appendText(` ${j.name}`),xe.addEventListener("change",()=>{xe.checked?d.allowedAgents.includes(j.name)||d.allowedAgents.push(j.name):d.allowedAgents=d.allowedAgents.filter(he=>he!==j.name)})}let $=_.createDiv({cls:"af-form-row af-form-row-toggle"}),Z=$.createDiv({cls:"af-form-label"});Z.setText("Per-user sessions"),this.addTooltip(Z,"Each external user gets their own isolated Claude session");let de=$.createDiv({cls:"af-agent-card-toggle on"});de.onclick=()=>{let j=de.hasClass("on");de.toggleClass("on",!j),d.perUserSessions=!j};let me=u.createDiv({cls:"af-create-section"}),ge=me.createDiv({cls:"af-create-section-header"}),X=ge.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(X,"shield-check"),ge.createSpan({text:"Access Control"});let W=me.createDiv({cls:"af-form-label"});W.setText("Allowed users"),this.addTooltip(W,"User IDs, one per line \u2014 Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot.");let K=me.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`U0AQW6P37N1 -U0BXYZ12345`,rows:"4"}});K.addEventListener("input",()=>{d.allowedUsers=K.value});let ne=u.createDiv({cls:"af-create-section"}),G=ne.createDiv({cls:"af-create-section-header"}),ee=G.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(ee,"message-square");let Q=G.createSpan({text:"Channel Context"});this.addTooltip(Q,"Extra instructions appended to the agent's system prompt when reached through this channel");let ae=ne.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"You are being contacted via Slack. Keep replies concise...",rows:"6"}});ae.addEventListener("input",()=>{d.channelContext=ae.value}),this.createFormField(p,"Tags","ops, internal","Comma-separated metadata",j=>{d.tags=j});let ve=u.createDiv({cls:"af-create-section"}),Pe=ve.createDiv({cls:"af-create-section-header"}),Ie=Pe.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Ie,"settings");let Me=Pe.createSpan({text:"Advanced"});this.addTooltip(Me,"Markdown body (shown in the channel detail page) and transport-specific overrides");let Fe=ve.createDiv({cls:"af-form-label"});Fe.setText("Body (markdown)"),this.addTooltip(Fe,"Free-form notes for this channel. Shown in the channel detail page; not sent to the agent.");let ke=ve.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Notes, runbook snippets, escalation contacts\u2026",rows:"4"}});ke.addEventListener("input",()=>{d.body=ke.value});let Oe=ve.createDiv({cls:"af-form-label"});Oe.setText("Transport config (JSON)"),this.addTooltip(Oe,"Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults.");let Ue=ve.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`{ +`}async getOrCreateSession(e,t,s){let n=`${e.name}:${t}:${s}`,r=this.sessions.get(n);if(r)return r.session;let i=this.deps.getRepository(),o=i.getAgentByName(s);if(!o)throw new Error(`Channel ${e.name} bound to missing agent ${s}`);let l=new ys(o,this.deps.getSettings(),i,this.deps.vault,{channelName:e.name,conversationId:`${t}:${s}`,channelContext:e.channelContext||void 0,mcpAuth:this.deps.getMcpAuth?.()});this.deps.recordUsage&&l.setUsageRecorder(this.deps.recordUsage);try{await l.loadPersistedState()}catch{}return this.sessions.set(n,{session:l,channelName:e.name,conversationId:t,sessionKey:n}),l}resolveAllowedAgents(e){let t=new Set(this.deps.getRepository().getSnapshot().agents.filter(n=>n.enabled).map(n=>n.name));return(e.allowedAgents.length>0?e.allowedAgents:[...t]).filter(n=>t.has(n))}async persistBindings(e){let t=`${e}:`,s={};for(let[o,l]of this.threadBindings)o.startsWith(t)&&(s[o.slice(t.length)]=l);let n=this.deps.getSettings(),r=(0,xt.normalizePath)(`${n.fleetFolder}/channels/${e}/bindings.json`),i=JSON.stringify(s,null,2);try{let o=this.deps.vault.getAbstractFileByPath(r);if(o instanceof xt.TFile)await this.deps.vault.modify(o,i);else{let l=r.slice(0,r.lastIndexOf("/"));if(!this.deps.vault.getAbstractFileByPath(l))try{await this.deps.vault.createFolder(l)}catch{}await this.deps.vault.create(r,i)}}catch(o){console.warn(`Agent Fleet: failed to persist thread bindings for ${e}`,o)}}async loadBindings(e){let t=this.deps.getSettings(),s=(0,xt.normalizePath)(`${t.fleetFolder}/channels/${e}/bindings.json`);try{let n=this.deps.vault.getAbstractFileByPath(s);if(!(n instanceof xt.TFile))return;let r=await this.deps.vault.cachedRead(n),i=JSON.parse(r);for(let[o,l]of Object.entries(i))typeof l=="string"&&this.threadBindings.set(`${e}:${o}`,l)}catch{}}getThreadAgent(e,t){return this.threadBindings.get(`${e}:${t}`)}enforceHardCap(){let e=Math.max(1,this.deps.getSettings().maxConcurrentChannelSessions),t=Array.from(this.sessions.values()).filter(n=>n.session.isProcessAlive&&!n.session.isStreaming);if(t.length<=e)return;t.sort((n,r)=>n.session.lastActiveAt-r.session.lastActiveAt);let s=t.length-e;for(let n=0;n{n=o}),i=(this.conversationLockGen.get(e)??0)+1;this.conversationLockGen.set(e,i),this.conversationLocks.set(e,s.then(()=>r));try{await s,await t()}finally{n(),this.conversationLockGen.get(e)===i&&(this.conversationLocks.delete(e),this.conversationLockGen.delete(e))}}ensureMetrics(e){let t=this.metrics.get(e);return t||(t={messagesReceived:0,messagesSent:0,lastMessageAt:null},this.metrics.set(e,t)),t}getMetrics(e){return{...this.ensureMetrics(e)}}getChannelStatus(e){let t=this.adapters.get(e);return t?t.getStatus():"disabled"}getConnectedCount(){let e=0;for(let t of this.adapters.values())t.getStatus()==="connected"&&(e+=1);return e}getSessionCount(e){let t=0,s=`${e}:`;for(let n of this.sessions.keys())n.startsWith(s)&&(t+=1);return t}onStatusChange(e){return this.statusListeners.add(e),()=>this.statusListeners.delete(e)}notifyStatusListeners(){for(let e of this.statusListeners)try{e()}catch{}}};function Qd(a){if(a.length===0)return"";let e=new Map;for(let s of a)e.set(s.name,(e.get(s.name)??0)+1);let t=[];for(let[s,n]of e)t.push(n>1?`${s}\xD7${n}`:s);return`Used ${a.length} tool${a.length===1?"":"s"}: ${t.join(", ")}`}var aa=class{credentials=new Map;secretStore;persistCallback;setSecretStore(e){this.secretStore=e}loadCredentials(e){if(this.credentials.clear(),this.secretStore?.available){let t=this.secretStore.listByPrefix(gs);for(let s of t){let n=gs+"-",r=s.startsWith(n)?s.slice(n.length):s,i=this.secretStore.getJson(gs,r);if(i){let o=i._ref??r,l={...i};delete l._ref,this.credentials.set(o,l)}}}if(this.credentials.size===0&&e){for(let[t,s]of Object.entries(e))this.credentials.set(t,s);this.secretStore?.available&&this.credentials.size>0&&this.persistToSecretStore()}}onChanged(e){this.persistCallback=e}get(e){return this.credentials.get(e)}set(e,t){this.credentials.set(e,t),this.persist()}delete(e){this.credentials.delete(e),this.secretStore?.delete(gs,e),this.persist()}list(){return Array.from(this.credentials.entries()).map(([e,t])=>({ref:e,entry:t}))}toRecord(){let e={};for(let[t,s]of this.credentials.entries())e[t]=s;return e}persist(){this.persistToSecretStore(),this.persistCallback&&this.persistCallback(this.toRecord())}persistToSecretStore(){if(this.secretStore?.available)for(let[e,t]of this.credentials)this.secretStore.setJson(gs,e,{...t,_ref:e})}};function _o(){return{status:"disconnected",scope:"user",tools:[],toolDetails:[]}}function Zd(a,e,t){if(t)return"stdio";let s=(a??"").toLowerCase();return s==="sse"?"sse":s==="http"||s==="streamable-http"||s==="streamable_http"?"http":e?.endsWith("/sse")?"sse":"http"}function Ao(a){let e=[],t={},s;try{s=JSON.parse(a)}catch{return{servers:e,tokens:t}}let n=s.mcpServers;if(!n||typeof n!="object")return{servers:e,tokens:t};for(let[r,i]of Object.entries(n)){if(!i||typeof i!="object")continue;let o=i,l=typeof o.command=="string"?o.command:void 0,c=typeof o.url=="string"?o.url:void 0;if(!l&&!c)continue;let d=Zd(typeof o.type=="string"?o.type:void 0,c,!!l),u={name:r,type:d,enabled:!0,source:"imported",..._o()};if(d==="stdio")u.command=l,Array.isArray(o.args)&&(u.args=o.args.filter(h=>typeof h=="string")),o.env&&typeof o.env=="object"&&(u.env=To(o.env));else{u.url=c;let h=o.headers&&typeof o.headers=="object"?To(o.headers):{},p=h.Authorization??h.authorization;p?.startsWith("Bearer ")?(t[r]=p.slice(7),delete h.Authorization,delete h.authorization,u.auth="oauth"):u.auth="none",Object.keys(h).length>0&&(u.headers=h)}e.push(u)}return{servers:e,tokens:t}}function Po(a){let e=new Map,t=null;for(let n of a.split(/\r?\n/)){let r=n.trim();if(!r||r.startsWith("#"))continue;let i=r.match(/^\[(.+)\]$/);if(i){let h=i[1].trim().match(/^mcp_servers\.(.+)$/);if(!h){t=null;continue}let p=h[1],m=null;p.endsWith(".env")?(m="env",p=p.slice(0,-4)):p.endsWith(".oauth")&&(m="oauth",p=p.slice(0,-6));let f=ra(p);if(!f){t=null;continue}e.has(f)||e.set(f,{name:f,enabled:!0}),t={name:f,sub:m};continue}if(!t)continue;let o=r.match(/^([^=]+?)\s*=\s*(.+)$/);if(!o)continue;let l=ra(o[1].trim()),c=o[2].trim(),d=e.get(t.name);if(t.sub==="env")d.env??={},d.env[l]=vs(c);else if(t.sub==="oauth")l==="client_id"&&(d.oauthClientId=vs(c));else switch(l){case"command":d.command=vs(c);break;case"args":d.args=eu(c);break;case"url":d.url=vs(c);break;case"bearer_token_env_var":d.bearerEnvVar=vs(c);break;case"oauth_resource":d.oauthResource=vs(c);break;case"enabled":d.enabled=c==="true";break;default:break}}let s=[];for(let n of e.values()){if(!n.command&&!n.url)continue;let r=n.command?"stdio":n.url?.endsWith("/sse")?"sse":"http",i={name:n.name,type:r,enabled:n.enabled,source:"imported",..._o()};r==="stdio"?(i.command=n.command,n.args&&n.args.length>0&&(i.args=n.args),n.env&&Object.keys(n.env).length>0&&(i.env=n.env)):(i.url=n.url,n.oauthClientId||n.oauthResource?(i.auth="oauth",i.oauth={clientId:n.oauthClientId,resource:n.oauthResource}):n.bearerEnvVar?(i.auth="bearer",i.envSecretKeys=[n.bearerEnvVar]):i.auth="none"),s.push(i)}return{servers:s,tokens:{}}}function Eo(...a){let e=new Map,t={};for(let s of a){for(let n of s.servers){let r=n.name.trim().toLowerCase();e.has(r)||e.set(r,n)}Object.assign(t,s.tokens)}return{servers:[...e.values()],tokens:t}}function ra(a){let e=a.trim();return e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'")?e.slice(1,-1):e}function vs(a){let e=a.trim();if(e.startsWith('"')||e.startsWith("'")){let t=e[0],s=e.indexOf(t,1);if(s>0)return e.slice(1,s)}return ra(e.replace(/\s+#.*$/,""))}function eu(a){let e=a.trim();try{let s=JSON.parse(e);if(Array.isArray(s))return s.filter(n=>typeof n=="string")}catch{}return e.replace(/^\[/,"").replace(/\]$/,"").split(",").map(s=>ra(s.trim())).filter(Boolean)}function To(a){let e={};for(let[t,s]of Object.entries(a))typeof s=="string"&&(e[t]=s);return e}var kh=st(Cl(),1),xh=st(ma(),1),Sh=st(ks(),1),Ch=st(Tr(),1),Th=st(Pr(),1),_h=st(Fr(),1),Ml=st(va(),1),Ah=st(Il(),1);var As=Ml.default;var Fl=require("obsidian");var Ht=class{base;cap;delayMs;constructor(e=1e3,t=3e4){this.base=e,this.cap=t,this.delayMs=e}nextDelay(){let e=this.delayMs;return this.delayMs=Math.min(this.cap,this.delayMs*2),e}reset(){this.delayMs=this.base}};var Ph="https://slack.com/api";function Eh(a){let e=a.team??"unknown",t=a.channel??"unknown",s=a.thread_ts??a.ts??"unknown";return`slack:${e}:${t}:thread:${s}`}function Dh(a){let e=a.split(":");return e.length>=3&&e[0]==="slack"?e[2]??null:null}function Rh(a){let e=a.split(":");if(e[3]==="thread"&&e[4])return e[4]}var ba=class{type="slack";config;credential;ws=null;status="stopped";stopping=!1;backoff=new Ht;reconnectTimer=null;inboundHandlers=new Set;statusHandlers=new Set;agentSwitchHandlers=new Set;allowedAgentsResolver;sendQueues=new Map;threadContext=new Map;constructor(e,t){if(t.type!=="slack")throw new Error(`SlackAdapter requires a slack credential, got ${t.type}`);this.config=e,this.credential=t}async start(){this.stopping=!1,await this.connect()}async stop(){if(this.stopping=!0,this.reconnectTimer&&(window.clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.ws){try{this.ws.close()}catch{}this.ws=null}this.threadContext.clear(),this.sendQueues.clear(),this.setStatus("stopped")}getStatus(){return this.status}async send(e,t){let s=Dh(e);if(!s){console.warn(`Agent Fleet: could not extract channel id from ${e}`);return}let n=Rh(e),r=Qs(t);await this.enqueueSend(s,async()=>{await this.slackApi("chat.postMessage",{channel:s,text:r,...n?{thread_ts:n}:{}})})}async sendToTarget(e,t){if(!e)return;let s=Qs(t);await this.enqueueSend(e,async()=>{await this.slackApi("chat.postMessage",{channel:e,text:s})})}async broadcast(e){let t=this.config.allowedUsers[0];if(!t){console.warn(`Agent Fleet: broadcast on ${this.config.name} skipped \u2014 no allowed users configured`);return}try{let n=(await this.slackApi("conversations.open",{users:t})).channel?.id;if(!n){console.warn(`Agent Fleet: broadcast \u2014 conversations.open returned no channel for user ${t}`);return}let r=Qs(e);await this.slackApi("chat.postMessage",{channel:n,text:r})}catch(s){console.error(`Agent Fleet: broadcast failed on ${this.config.name}`,s)}}async setThreadTitle(e,t){let s=this.threadContext.get(e);if(s)try{await this.slackApi("assistant.threads.setTitle",{channel_id:s.channelId,thread_ts:s.threadTs,title:t})}catch(n){console.warn(`Agent Fleet: assistant.threads.setTitle failed on ${this.config.name}`,n)}}async setTyping(e,t){let s=this.threadContext.get(e);if(s)try{await this.slackApi("assistant.threads.setStatus",{channel_id:s.channelId,thread_ts:s.threadTs,status:t?"is thinking...":""})}catch(n){console.warn(`Agent Fleet: assistant.threads.setStatus (${t?"on":"off"}) failed on ${this.config.name}`,n)}}onInbound(e){return this.inboundHandlers.add(e),()=>this.inboundHandlers.delete(e)}onStatusChange(e){return this.statusHandlers.add(e),()=>this.statusHandlers.delete(e)}setAllowedAgentsResolver(e){this.allowedAgentsResolver=e}onAgentSwitch(e){return this.agentSwitchHandlers.add(e),()=>this.agentSwitchHandlers.delete(e)}async connect(){if(this.stopping)return;this.setStatus(this.ws?"reconnecting":"connecting");let e;try{let t=await this.slackApi("apps.connections.open",{},{useAppToken:!0});if(!t.ok||!t.url)throw new Error(t.error??"apps.connections.open returned no URL");e=t.url}catch(t){console.error(`Agent Fleet: Slack apps.connections.open failed for channel ${this.config.name}`,t),this.setStatus("needs-auth"),this.scheduleReconnect();return}try{let t=new As(e);t.on("open",()=>{}),t.on("message",r=>{this.handleSocketData(r)}),t.on("error",r=>{console.warn(`Agent Fleet: Slack WebSocket error on ${this.config.name}`,r),this.setStatus("error")}),t.on("close",()=>{this.ws=null,this.stopping||this.scheduleReconnect()}),this.ws=t;let s=window.setTimeout(()=>{if(this.status==="connecting"||this.status==="reconnecting"){console.warn(`Agent Fleet: Slack WebSocket connect timeout on ${this.config.name}`);try{t.close()}catch{}}},3e4);t.on("close",()=>window.clearTimeout(s));let n=this.onStatusChange(r=>{r==="connected"&&(window.clearTimeout(s),n())})}catch(t){console.error("Agent Fleet: Slack WebSocket open failed",t),this.setStatus("error"),this.scheduleReconnect();return}}handleSocketData(e){let t;try{t=JSON.parse(e.toString())}catch{return}if(t.type==="hello"){this.backoff.reset(),this.setStatus("connected");return}if(t.type==="disconnect"){try{this.ws?.close()}catch{}return}if(t.type==="events_api"&&t.envelope_id){this.ackEnvelope(t.envelope_id),this.routeEventPayload(t.payload);return}if(t.type==="slash_commands"&&t.envelope_id){this.ackEnvelope(t.envelope_id),this.handleSlashCommand(t.payload);return}if(t.type==="interactive"&&t.envelope_id){this.ackEnvelope(t.envelope_id),this.handleInteraction(t.payload);return}t.envelope_id&&this.ackEnvelope(t.envelope_id)}async handleSlashCommand(e){if(!e)return;let t=e.command,s=e.channel_id,n=e.user_id;if(!(!t||!s||!n)){if(t==="/agents"){let r=this.allowedAgentsResolver?this.allowedAgentsResolver():this.config.allowedAgents;if(r.length===0){await this.slackApi("chat.postEphemeral",{channel:s,user:n,text:"No agents available. Add existing, enabled agents to `allowed_agents` in the channel file."});return}let i=r.map(l=>({type:"button",text:{type:"plain_text",text:l===this.config.defaultAgent?`${l} \u2713`:l,emoji:!0},action_id:`switch_agent_${l}`,value:l})),o=[{type:"section",text:{type:"mrkdwn",text:"*Select an agent to chat with:*"}}];for(let l=0;l\s*/,"").trim(),!n.text))return;let o=Eh(n),l=n.thread_ts??n.ts;if(n.channel&&l&&(this.threadContext.set(o,{channelId:n.channel,threadTs:l}),this.threadContext.size>500)){let u=this.threadContext.keys().next();u.done||this.threadContext.delete(u.value)}let c={conversationId:o,externalUserId:n.user,text:n.text,timestamp:new Date().toISOString(),meta:{slack_channel:n.channel,slack_ts:n.ts,thread_ts:n.thread_ts}};for(let d of this.inboundHandlers)try{d(c)}catch(u){console.error("Agent Fleet: Slack inbound handler threw",u)}}scheduleReconnect(){if(this.stopping||this.reconnectTimer)return;let e=this.backoff.nextDelay();console.warn(`Agent Fleet: Slack channel ${this.config.name} scheduling reconnect in ${e}ms`),this.reconnectTimer=window.setTimeout(()=>{this.reconnectTimer=null,!this.stopping&&this.connect()},e)}setStatus(e){if(this.status!==e){this.status=e;for(let t of this.statusHandlers)try{t(e)}catch{}}}async slackApi(e,t,s={}){let n=s.useAppToken?this.credential.appToken:this.credential.botToken,r=`${Ph}/${e}`,i=await(0,Fl.requestUrl)({url:r,method:"POST",contentType:"application/json; charset=utf-8",headers:{Authorization:`Bearer ${n}`},body:JSON.stringify(t),throw:!1});if(i.status===429){let l=Number(i.headers["retry-after"]??"1");return await new Promise(c=>window.setTimeout(c,Math.max(1e3,l*1e3))),this.slackApi(e,t,s)}if(i.status<200||i.status>=300)throw new Error(`Slack ${e} HTTP ${i.status}`);let o=i.json;if(o.ok===!1)throw new Error(`Slack ${e} error: ${o.error??"unknown"}`);return o}async enqueueSend(e,t){let n=(this.sendQueues.get(e)??Promise.resolve()).then(async()=>{try{await t()}finally{await new Promise(i=>window.setTimeout(i,1e3))}}),r=n.catch(i=>{console.warn(`Agent Fleet: Slack send queue error for ${e}`,i)});this.sendQueues.set(e,r);try{await n}finally{this.sendQueues.get(e)===r&&this.sendQueues.delete(e)}}};var Or=require("obsidian");var Ll="https://api.telegram.org",ka=class{type="telegram";config;credential;status="stopped";stopping=!1;pollOffset=0;pollTimer=null;backoff=new Ht;typingIntervals=new Map;pollAbort=null;inboundHandlers=new Set;statusHandlers=new Set;agentSwitchHandlers=new Set;allowedAgentsResolver;constructor(e,t){if(t.type!=="telegram")throw new Error(`TelegramAdapter requires a telegram credential, got ${t.type}`);this.config=e,this.credential=t}async start(){this.stopping=!1;try{let t=(await this.tgApi("getUpdates",{offset:-1,limit:1})).result?.[0];t&&(this.pollOffset=t.update_id+1)}catch{}try{await this.tgApi("setMyCommands",{commands:[{command:"agents",description:"List available agents"}]})}catch{}this.setStatus("connected"),this.poll()}async stop(){this.stopping=!0,this.pollTimer&&(window.clearTimeout(this.pollTimer),this.pollTimer=null),this.pollAbort?.abort(),this.pollAbort=null;for(let[,e]of this.typingIntervals)window.clearInterval(e);this.typingIntervals.clear(),this.setStatus("stopped")}getStatus(){return this.status}async send(e,t){let s=Ol(e);if(!s)return;let n=Nl(e),r=ss(t,4096);for(let i of r)await this.tgApi("sendMessage",{chat_id:s,text:i,parse_mode:"Markdown",...n?{message_thread_id:Number(n)}:{}})}async sendToTarget(e,t){if(!e)return;let s=ss(t,4096);for(let n of s)await this.tgApi("sendMessage",{chat_id:e,text:n,parse_mode:"Markdown"})}async setTyping(e,t){let s=Ol(e);if(!s)return;let n=Nl(e),r={chat_id:s,action:"typing"};if(n&&(r.message_thread_id=Number(n)),t){let i=this.typingIntervals.get(e);i&&window.clearInterval(i);try{await this.tgApi("sendChatAction",r)}catch(l){console.warn("Agent Fleet: Telegram sendChatAction failed",l)}let o=window.setInterval(()=>{this.tgApi("sendChatAction",r).catch(()=>{})},4500);this.typingIntervals.set(e,o)}else{let i=this.typingIntervals.get(e);i&&(window.clearInterval(i),this.typingIntervals.delete(e))}}async setThreadTitle(e,t){}async broadcast(e){let t=this.config.allowedUsers[0];if(t)try{let s=ss(e,4096);for(let n of s)await this.tgApi("sendMessage",{chat_id:t,text:n,parse_mode:"Markdown"})}catch(s){console.error(`Agent Fleet: Telegram broadcast failed on ${this.config.name}`,s)}}onInbound(e){return this.inboundHandlers.add(e),()=>this.inboundHandlers.delete(e)}onStatusChange(e){return this.statusHandlers.add(e),()=>this.statusHandlers.delete(e)}setAllowedAgentsResolver(e){this.allowedAgentsResolver=e}pickerAgents(){return this.allowedAgentsResolver?this.allowedAgentsResolver():this.config.allowedAgents}onAgentSwitch(e){return this.agentSwitchHandlers.add(e),()=>this.agentSwitchHandlers.delete(e)}poll(){this.stopping||(async()=>{try{this.pollAbort=new AbortController;let e=await this.tgApi("getUpdates",{offset:this.pollOffset,timeout:30,allowed_updates:["message","callback_query"]},this.pollAbort.signal);if(e.ok&&e.result)for(let t of e.result){if(this.pollOffset=t.update_id+1,t.callback_query){this.handleCallbackQuery(t.callback_query);continue}t.message&&this.routeMessage(t.message)}this.backoff.reset(),this.status!=="connected"&&this.setStatus("connected")}catch(e){if(e instanceof DOMException&&e.name==="AbortError")return;if(console.warn(`Agent Fleet: Telegram poll failed on ${this.config.name}`,e),this.status!=="error"&&this.status!=="needs-auth"){let t=e instanceof Error?e.message:String(e);this.setStatus(t.includes("401")||t.includes("Unauthorized")?"needs-auth":"error")}await new Promise(t=>window.setTimeout(t,this.backoff.nextDelay()))}this.stopping||(this.pollTimer=window.setTimeout(()=>this.poll(),100))})()}routeMessage(e){let t=e.photo&&e.photo.length>0,s=e.document&&this.isImageMime(e.document.mime_type),n=!!e.text;if(!e.from||!n&&!t&&!s)return;let r=e.text??e.caption??"";if(r==="/agents"||r.startsWith("/agents@")){this.handleAgentsCommand(e);return}if(r.startsWith("/"))return;let i=String(e.from.id),o=String(e.chat.id),l=e.message_thread_id?String(e.message_thread_id):void 0,c=l?`tg:${o}:topic:${l}`:`tg:${o}`;this.buildAndEmitMessage(e,r,c,i,o,l)}async buildAndEmitMessage(e,t,s,n,r,i){let o=[];try{if(e.photo&&e.photo.length>0){let c=e.photo[e.photo.length-1],d=await this.downloadFile(c.file_id,`photo_${e.message_id}.jpg`,"image/jpeg");d&&o.push(d)}if(e.document&&this.isImageMime(e.document.mime_type)){let c=e.document.file_name??`doc_${e.message_id}`,d=e.document.mime_type??"image/jpeg",u=await this.downloadFile(e.document.file_id,c,d);u&&o.push(u)}}catch(c){console.warn("Agent Fleet: Telegram image download failed",c)}let l={conversationId:s,externalUserId:n,text:t,timestamp:new Date(e.date*1e3).toISOString(),meta:{telegram_chat_id:r,telegram_message_id:e.message_id,telegram_thread_id:i,chat_type:e.chat.type},...o.length>0?{images:o}:{}};for(let c of this.inboundHandlers)try{c(l)}catch(d){console.error("Agent Fleet: Telegram inbound handler threw",d)}}async downloadFile(e,t,s){let r=(await this.tgApi("getFile",{file_id:e})).result?.file_path;if(!r)return null;let i=`${Ll}/file/bot${this.credential.botToken}/${r}`;return{data:(await(0,Or.requestUrl)({url:i,method:"GET"})).arrayBuffer,filename:t,mimeType:s}}isImageMime(e){return e?e==="image/jpeg"||e==="image/png"||e==="image/gif"||e==="image/webp"||e==="image/bmp"||e==="image/tiff":!1}async handleAgentsCommand(e){let t=String(e.chat.id),s=e.message_thread_id,n=this.pickerAgents();if(n.length===0){await this.tgApi("sendMessage",{chat_id:t,text:"No agents available. Add existing, enabled agents to `allowed_agents` in the channel file.",...s?{message_thread_id:s}:{}});return}let r=n.map(i=>[{text:i===this.config.defaultAgent?`${i} \u2713`:i,callback_data:`switch:${i}`}]);await this.tgApi("sendMessage",{chat_id:t,text:"*Select an agent to chat with:*",parse_mode:"Markdown",reply_markup:{inline_keyboard:r},...s?{message_thread_id:s}:{}})}async handleCallbackQuery(e){let t=e.data;if(!t?.startsWith("switch:")){await this.tgApi("answerCallbackQuery",{callback_query_id:e.id});return}let s=t.slice(7),n=String(e.from.id),r=String(e.message?.chat?.id??e.from.id),i=e.message?.message_thread_id?String(e.message.message_thread_id):void 0,o=i?`tg:${r}:topic:${i}`:`tg:${r}`;for(let l of this.agentSwitchHandlers)try{l(o,s,n)}catch(c){console.error("Agent Fleet: Telegram agent switch handler threw",c)}if(await this.tgApi("answerCallbackQuery",{callback_query_id:e.id,text:`Switched to ${s}`}),e.message){let c=this.pickerAgents().map(d=>[{text:d===s?`${d} \u2713`:d,callback_data:`switch:${d}`}]);try{await this.tgApi("editMessageText",{chat_id:r,message_id:e.message.message_id,text:`*Active agent: ${s}*`,parse_mode:"Markdown",reply_markup:{inline_keyboard:c}})}catch{}}}async tgApi(e,t,s){if(s?.aborted)throw new DOMException("Aborted","AbortError");let n=`${Ll}/bot${this.credential.botToken}/${e}`,r=(0,Or.requestUrl)({url:n,method:"POST",contentType:"application/json",body:JSON.stringify(t),throw:!1}),i;if(s?i=await Promise.race([r,new Promise((o,l)=>{s.addEventListener("abort",()=>l(new DOMException("Aborted","AbortError")),{once:!0})})]):i=await r,i.status===401||i.status===403)throw new Error(`Telegram ${e} ${i.status} Unauthorized`);if(i.status===429){let l=i.json?.parameters?.retry_after??1;return await new Promise(c=>window.setTimeout(c,l*1e3)),this.tgApi(e,t)}if(i.status<200||i.status>=300)throw new Error(`Telegram ${e} HTTP ${i.status}: ${i.text}`);return i.json}setStatus(e){if(this.status!==e){this.status=e;for(let t of this.statusHandlers)try{t(e)}catch{}}}};function Ol(a){return a.startsWith("tg:")?a.split(":")[1]??null:null}function Nl(a){let e=a.split(":");if(e[2]==="topic"&&e[3])return e[3]}var Br=require("obsidian");var Ih="https://discord.com/api/v10",Mh="wss://gateway.discord.gg",Fh="?v=10&encoding=json",Lh="DiscordBot (https://github.com/denberek/obsidian-agent-fleet, 0.13.6)",Oh=37376,Nh=0,Nr=1,Bh=2,Uh=6,$h=7,jh=9,Hh=10,Wh=11,qh=2,zh=3,Bl=4,Gh=7,Ul=64,xa=class{type="discord";config;credential;ws=null;status="stopped";stopping=!1;backoff=new Ht;reconnectTimer=null;sessionId=null;resumeGatewayUrl=null;seq=null;canResume=!1;heartbeatTimer=null;heartbeatInitialTimer=null;heartbeatAcked=!0;selfUserId=null;applicationId=null;commandRegistered=!1;warnedEmptyContent=!1;typingIntervals=new Map;sendQueues=new Map;inboundHandlers=new Set;statusHandlers=new Set;agentSwitchHandlers=new Set;allowedAgentsResolver;constructor(e,t){if(t.type!=="discord")throw new Error(`DiscordAdapter requires a discord credential, got ${t.type}`);this.config=e,this.credential=t}async start(){this.stopping=!1,this.backoff.reset(),this.canResume=!1,await this.connect()}async stop(){this.stopping=!0,this.reconnectTimer&&(window.clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.clearHeartbeat();for(let[,e]of this.typingIntervals)window.clearInterval(e);this.typingIntervals.clear();try{this.ws?.close()}catch{}this.ws=null,this.setStatus("stopped")}getStatus(){return this.status}async send(e,t){let s=jl(e);s&&await this.sendToTarget(s,t)}async sendToTarget(e,t){if(!e)return;let s=ss(t,2e3);await this.enqueueSend(e,async()=>{for(let n of s)await this.discordApi("POST",`/channels/${e}/messages`,{content:n})})}async setTyping(e,t){let s=jl(e);if(s)if(t){let n=this.typingIntervals.get(e);n&&window.clearInterval(n);try{await this.discordApi("POST",`/channels/${s}/typing`)}catch(i){console.warn("Agent Fleet: Discord typing trigger failed",i)}let r=window.setInterval(()=>{this.discordApi("POST",`/channels/${s}/typing`).catch(()=>{})},8e3);this.typingIntervals.set(e,r)}else{let n=this.typingIntervals.get(e);n&&(window.clearInterval(n),this.typingIntervals.delete(e))}}async broadcast(e){let t=this.config.allowedUsers[0];if(t)try{let n=(await this.discordApi("POST","/users/@me/channels",{recipient_id:t}))?.id;if(!n)return;let r=ss(e,2e3);for(let i of r)await this.discordApi("POST",`/channels/${n}/messages`,{content:i})}catch(s){console.error(`Agent Fleet: Discord broadcast failed on ${this.config.name}`,s)}}onInbound(e){return this.inboundHandlers.add(e),()=>this.inboundHandlers.delete(e)}onStatusChange(e){return this.statusHandlers.add(e),()=>this.statusHandlers.delete(e)}onAgentSwitch(e){return this.agentSwitchHandlers.add(e),()=>this.agentSwitchHandlers.delete(e)}setAllowedAgentsResolver(e){this.allowedAgentsResolver=e}pickerAgents(){return this.allowedAgentsResolver?this.allowedAgentsResolver():this.config.allowedAgents}async connect(){if(this.stopping)return;this.setStatus(this.ws?"reconnecting":"connecting");let t=`${this.canResume&&this.resumeGatewayUrl?this.resumeGatewayUrl:Mh}${Fh}`;try{let s=new As(t);s.on("message",i=>{this.handleFrame(i)}),s.on("error",i=>{console.warn(`Agent Fleet: Discord WebSocket error on ${this.config.name}`,i)}),s.on("close",i=>{this.ws=null,this.clearHeartbeat(),this.handleClose(i)}),this.ws=s;let n=window.setTimeout(()=>{if(this.status==="connecting"||this.status==="reconnecting"){console.warn(`Agent Fleet: Discord WebSocket connect timeout on ${this.config.name}`);try{s.close()}catch{}}},3e4);s.on("close",()=>window.clearTimeout(n));let r=this.onStatusChange(i=>{i==="connected"&&(window.clearTimeout(n),r())})}catch(s){console.error("Agent Fleet: Discord WebSocket open failed",s),this.setStatus("error"),this.scheduleReconnect()}}handleClose(e){let t=!1;e===4014?(console.error(`Agent Fleet: Discord channel ${this.config.name} \u2014 disallowed intents (4014). Enable the Message Content intent in the Developer Portal \u2192 your app \u2192 Bot \u2192 Privileged Gateway Intents, then reload.`),this.canResume=!1,this.setStatus("needs-auth"),t=!0):e===4004||e===4013?(console.error(`Agent Fleet: Discord channel ${this.config.name} \u2014 gateway auth/intents error (${e}). Check the bot token credential, then reload.`),this.canResume=!1,this.setStatus("needs-auth"),t=!0):(e===4007||e===4009)&&(this.canResume=!1),!this.stopping&&!t&&this.scheduleReconnect()}handleFrame(e){let t;try{t=JSON.parse(e.toString())}catch{return}switch(typeof t.s=="number"&&(this.seq=t.s),t.op){case Hh:{let s=t.d?.heartbeat_interval;this.startHeartbeat(s??41250),this.canResume&&this.sessionId&&this.seq!==null?this.sendGateway(Uh,{token:this.credential.botToken,session_id:this.sessionId,seq:this.seq}):this.identify();return}case Nr:{this.sendGateway(Nr,this.seq);return}case Wh:{this.heartbeatAcked=!0;return}case $h:{try{this.ws?.close()}catch{}return}case jh:{t.d===!0||(this.canResume=!1,this.sessionId=null),window.setTimeout(()=>{try{this.ws?.close()}catch{}},1e3+Math.floor(Math.random()*4e3));return}case Nh:{this.handleDispatch(t.t??"",t.d);return}default:return}}handleDispatch(e,t){if(e==="READY"){let s=t;this.sessionId=s.session_id??null,this.resumeGatewayUrl=s.resume_gateway_url??null,this.selfUserId=s.user?.id??null,this.applicationId=s.application?.id??null,this.canResume=!0,this.backoff.reset(),this.setStatus("connected"),this.registerAgentsCommand();return}if(e==="RESUMED"){this.backoff.reset(),this.setStatus("connected");return}if(e==="MESSAGE_CREATE"){this.routeMessage(t);return}if(e==="INTERACTION_CREATE"){this.handleInteraction(t);return}}identify(){this.sendGateway(Bh,{token:this.credential.botToken,intents:Oh,properties:{os:"linux",browser:"agent-fleet",device:"agent-fleet"}})}sendGateway(e,t){if(!(!this.ws||this.ws.readyState!==As.OPEN))try{this.ws.send(JSON.stringify({op:e,d:t}))}catch(s){console.warn("Agent Fleet: Discord gateway send failed",s)}}startHeartbeat(e){this.clearHeartbeat(),this.heartbeatAcked=!0;let t=()=>{if(!this.heartbeatAcked){console.warn(`Agent Fleet: Discord heartbeat not ACKed on ${this.config.name} \u2014 reconnecting`);try{this.ws?.close()}catch{}return}this.heartbeatAcked=!1,this.sendGateway(Nr,this.seq)};this.heartbeatInitialTimer=window.setTimeout(()=>{t(),this.heartbeatTimer=window.setInterval(t,e)},Math.floor(e*Math.random()))}clearHeartbeat(){this.heartbeatInitialTimer&&(window.clearTimeout(this.heartbeatInitialTimer),this.heartbeatInitialTimer=null),this.heartbeatTimer&&(window.clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}scheduleReconnect(){if(this.stopping||this.reconnectTimer)return;let e=this.backoff.nextDelay();console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${e}ms`),this.reconnectTimer=window.setTimeout(()=>{this.reconnectTimer=null,!this.stopping&&this.connect()},e)}routeMessage(e){if(!e.author||e.author.bot||this.selfUserId&&e.author.id===this.selfUserId||!e.channel_id)return;let t=(e.attachments??[]).filter(r=>typeof r.content_type=="string"&&r.content_type.startsWith("image/")),s=Vh(e.content??"",this.selfUserId);if(!s&&t.length===0){this.warnedEmptyContent||(this.warnedEmptyContent=!0,console.warn(`Agent Fleet: Discord channel ${this.config.name} received a message with empty content. If this persists, enable the Message Content intent in the Developer Portal.`));return}let n=$l(e.guild_id,e.channel_id);this.buildAndEmitMessage(e,s,n,t)}async buildAndEmitMessage(e,t,s,n){let r=[];for(let o of n)try{let l=await(0,Br.requestUrl)({url:o.url,method:"GET"});r.push({data:l.arrayBuffer,filename:o.filename||`attachment_${e.id}`,mimeType:o.content_type??"image/jpeg"})}catch(l){console.warn("Agent Fleet: Discord attachment download failed",l)}let i={conversationId:s,externalUserId:e.author.id,text:t,timestamp:e.timestamp??new Date().toISOString(),meta:{discord_guild_id:e.guild_id,discord_channel_id:e.channel_id,discord_message_id:e.id,is_dm:!e.guild_id},...r.length>0?{images:r}:{}};for(let o of this.inboundHandlers)try{o(i)}catch(l){console.error("Agent Fleet: Discord inbound handler threw",l)}}async registerAgentsCommand(){if(!(this.commandRegistered||!this.applicationId))try{await this.discordApi("PUT",`/applications/${this.applicationId}/commands`,[{name:"agents",description:"Switch the active agent",type:1}]),this.commandRegistered=!0}catch(e){console.warn(`Agent Fleet: Discord /agents command registration failed on ${this.config.name}`,e)}}async handleInteraction(e){if(e.type===qh){e.data?.name==="agents"&&await this.respondWithAgentPicker(e);return}if(e.type===zh){let t=e.data?.custom_id??"";if(!t.startsWith("switch:"))return;let s=t.slice(7),n=e.member?.user?.id??e.user?.id,r=e.channel_id;if(!n||!r)return;let i=$l(e.guild_id,r);for(let o of this.agentSwitchHandlers)try{o(i,s,n)}catch(l){console.error("Agent Fleet: Discord agent switch handler threw",l)}await this.respondToInteraction(e,Gh,{content:`Active agent: **${s}**`,components:this.buildAgentButtons(s)});return}}async respondWithAgentPicker(e){if(this.pickerAgents().length===0){await this.respondToInteraction(e,Bl,{content:"No agents available. Add existing, enabled agents to `allowed_agents` in the channel file.",flags:Ul});return}await this.respondToInteraction(e,Bl,{content:"Select an agent to chat with:",flags:Ul,components:this.buildAgentButtons(this.config.defaultAgent)})}buildAgentButtons(e){let t=this.pickerAgents().slice(0,25),s=[];for(let n=0;n({type:2,style:i===e?1:2,label:i===e?`${i} \u2713`:i,custom_id:`switch:${i}`}))})}return s}async respondToInteraction(e,t,s){try{await this.discordApi("POST",`/interactions/${e.id}/${e.token}/callback`,{type:t,data:s})}catch(n){console.warn("Agent Fleet: Discord interaction response failed",n)}}async discordApi(e,t,s){let n=await(0,Br.requestUrl)({url:`${Ih}${t}`,method:e,contentType:"application/json",headers:{Authorization:`Bot ${this.credential.botToken}`,"User-Agent":Lh},...s!==void 0?{body:JSON.stringify(s)}:{},throw:!1});if(n.status===429){let r=n.json?.retry_after??Number(n.headers["retry-after"]??"1");return await new Promise(i=>window.setTimeout(i,Math.max(1e3,r*1e3))),this.discordApi(e,t,s)}if(n.status===401||n.status===403)throw new Error(`Discord ${e} ${t} ${n.status}: ${Yh(n.text)}`);if(n.status<200||n.status>=300)throw new Error(`Discord ${e} ${t} HTTP ${n.status}: ${n.text}`);if(n.status!==204)return n.json}async enqueueSend(e,t){let n=(this.sendQueues.get(e)??Promise.resolve()).then(t),r=n.catch(()=>{});this.sendQueues.set(e,r);try{await n}finally{this.sendQueues.get(e)===r&&this.sendQueues.delete(e)}}setStatus(e){if(this.status!==e){this.status=e;for(let t of this.statusHandlers)try{t(e)}catch{}}}};function $l(a,e){return a?`discord:${a}:${e}`:`discord:dm:${e}`}function jl(a){let e=a.split(":");return e[0]!=="discord"?null:e[2]??null}function Vh(a,e){let t=a.trimStart();if(e){let s=new RegExp(`^<@!?${e}>\\s*`);t=t.replace(s,"")}return t.trim()}function Yh(a){let e=a??"";try{let t=JSON.parse(e);if(t&&(t.message!==void 0||t.code!==void 0))return`${t.message??"error"} (code ${t.code??"?"})`}catch{}return e||"no body"}var on=require("obsidian");var Sa=class extends on.ItemView{constructor(t,s){super(t);this.plugin=s}getViewType(){return us}getDisplayText(){return"Agent Fleet"}getIcon(){return"bot"}async onOpen(){this.plugin.subscribeView(this),await this.render()}async onClose(){this.plugin.unsubscribeView(this)}async render(){let t=this.contentEl;t.empty(),t.addClass("af-sidebar");let s=this.plugin.runtime.getSnapshot(),n=this.plugin.runtime.getFleetStatus(),r=t.createDiv({cls:"af-sidebar-section"});r.createDiv({cls:"af-sidebar-section-header",text:"AGENT FLEET"});let i=[{icon:"layout-dashboard",label:"Dashboard",page:"dashboard"},{icon:"bot",label:"Agents",page:"agents",badge:()=>s.agents.length},{icon:"columns-3",label:"Tasks Board",page:"kanban"},{icon:"scroll-text",label:"Run History",page:"runs"},{icon:"shield-check",label:"Approvals",page:"approvals",badge:()=>n.pending},{icon:"puzzle",label:"Skills",page:"skills",badge:()=>s.skills.length},{icon:"plug",label:"MCP Servers",page:"mcp",badge:()=>this.plugin.repository.getMcpServers().length},{icon:"radio",label:"Channels",page:"channels",badge:()=>this.plugin.channelManager?.getConnectedCount()??s.channels.length}];for(let c of i){let d=r.createDiv({cls:"af-sidebar-nav-item"}),u=d.createSpan({cls:"af-sidebar-nav-icon"});(0,on.setIcon)(u,c.icon),d.createSpan({cls:"af-sidebar-nav-label",text:c.label});let h=c.badge?.();h!==void 0&&h>0&&d.createSpan({cls:"af-sidebar-badge",text:String(h)}),d.setAttribute("role","button"),d.setAttribute("tabindex","0"),d.onclick=()=>void this.plugin.navigateDashboard(c.page),d.onkeydown=p=>{(p.key==="Enter"||p.key===" ")&&(p.preventDefault(),this.plugin.navigateDashboard(c.page))}}let o=t.createDiv({cls:"af-sidebar-section"});o.createDiv({cls:"af-sidebar-section-header",text:"AGENTS"}),s.agents.length===0&&o.createDiv({cls:"af-sidebar-empty",text:"No agents configured"});for(let c of s.agents){let d=this.plugin.runtime.getAgentState(c.name),u=o.createDiv({cls:"af-sidebar-agent-item"});u.createSpan({cls:`af-sidebar-agent-dot ${this.healthToClass(d.status)}`}),u.createSpan({cls:"af-sidebar-agent-name",text:c.name}),u.setAttribute("role","button"),u.setAttribute("tabindex","0"),u.onclick=()=>void this.plugin.navigateDashboard("agent-detail",c.name),u.onkeydown=h=>{(h.key==="Enter"||h.key===" ")&&(h.preventDefault(),this.plugin.navigateDashboard("agent-detail",c.name))}}let l=t.createDiv({cls:"af-sidebar-section"});l.createDiv({cls:"af-sidebar-section-header",text:"QUICK ACTIONS"}),this.renderQuickAction(l,"plus","New Agent",()=>void this.plugin.createAgentTemplate()),this.renderQuickAction(l,"plus","New Task",()=>void this.plugin.openCreateTask()),this.renderQuickAction(l,"plus","New Skill",()=>void this.plugin.createSkillTemplate())}renderQuickAction(t,s,n,r){let i=t.createDiv({cls:"af-sidebar-action-item"}),o=i.createSpan({cls:"af-sidebar-action-icon"});(0,on.setIcon)(o,s),i.createSpan({text:n}),i.setAttribute("role","button"),i.setAttribute("tabindex","0"),i.onclick=r,i.onkeydown=l=>{(l.key==="Enter"||l.key===" ")&&(l.preventDefault(),r())}}healthToClass(t){switch(t){case"running":return"running";case"error":return"error";case"pending":return"pending";case"disabled":return"disabled";default:return"idle"}}};var Z=require("obsidian");var Kh=[{match:/opus/i,rates:{input:15,output:75,cacheWrite:18.75,cacheRead:1.5}},{match:/sonnet/i,rates:{input:3,output:15,cacheWrite:3.75,cacheRead:.3}},{match:/haiku/i,rates:{input:1,output:5,cacheWrite:1.25,cacheRead:.1}},{match:/gpt-5|codex|o[0-9]/i,rates:{input:1.25,output:10,cacheWrite:1.25,cacheRead:.125}}],Jh={input:3,output:15,cacheWrite:3.75,cacheRead:.3};function Hl(a){for(let{match:e,rates:t}of Kh)if(e.test(a))return t;return Jh}function Wl(a,e){let t=Hl(a);return(e.inputTokens*t.input+e.outputTokens*t.output+e.cacheCreateTokens*t.cacheWrite+e.cacheReadTokens*t.cacheRead)/1e6}function ql(a,e){let t=Hl(a),s=.7*t.input+.3*t.output;return e*s/1e6}var zl=require("obsidian");function T(a,e,t){let s=a.createSpan({cls:t??"af-icon"});return(0,zl.setIcon)(s,e),s}function ot(a,e={}){let t=activeDocument.createElementNS("http://www.w3.org/2000/svg",a);for(let[s,n]of Object.entries(e))t.setAttribute(s,n);return t}function Xh(a){try{return new Date(a+"T12:00:00").toLocaleDateString(void 0,{month:"short",day:"numeric"})}catch{return a.slice(5)}}function Gl(a,e){let t=e.length||1,s=1e3,n=6,r=4,i=4,o=s-r-i-n*(t-1),l=Math.floor(o/t),c=140,d=36,u=18,h=u+c+d,p=Math.max(1,...e.map(f=>f.success+f.failure+f.cancelled)),m=ot("svg",{viewBox:`0 0 ${s} ${h}`,width:"100%",height:String(h),class:"af-chart-bar"});for(let f=0;f<=4;f++){let g=u+c-f/4*c;m.appendChild(ot("line",{x1:String(r),y1:String(g),x2:String(s-i),y2:String(g),stroke:"var(--af-text-faint)","stroke-opacity":"0.15","stroke-width":"1"}))}for(let f=0;f0&&m.appendChild(ot("rect",{x:String(w),y:String(u+c-k),width:String(l),height:String(Math.max(k,2)),fill:"var(--af-green)",opacity:"0.85"})),g.cancelled>0&&m.appendChild(ot("rect",{x:String(w),y:String(u+c-k-b),width:String(l),height:String(Math.max(b,2)),fill:"var(--af-yellow)",opacity:"0.85"})),g.failure>0&&m.appendChild(ot("rect",{x:String(w),y:String(u+c-v),width:String(l),height:String(Math.max(P,2)),fill:"var(--af-red)",opacity:"0.85"})),y===0&&m.appendChild(ot("rect",{x:String(w),y:String(u+c-3),width:String(l),height:"3",rx:"1.5",fill:"var(--af-text-faint)",opacity:"0.2"})),y>0){let M=ot("text",{x:String(w+l/2),y:String(u+c-v-5),"text-anchor":"middle","font-size":"10","font-weight":"600",fill:"var(--af-text-secondary)"});M.textContent=String(y),m.appendChild(M)}let A=ot("text",{x:String(w+l/2),y:String(u+c+16),"text-anchor":"middle","font-size":"10",fill:"var(--af-text-muted)"});A.textContent=Xh(g.date),m.appendChild(A)}a.appendChild(m)}function Vl(a,e,t){let l=2*Math.PI*46,c=t>0?e/t:0,d=l*c,u=l-d,h=ot("svg",{viewBox:"0 0 130 130",width:String(130),height:String(130),class:"af-chart-donut"});h.appendChild(ot("circle",{cx:String(65),cy:String(65),r:String(46),fill:"none",stroke:t>0?"var(--af-red)":"var(--af-text-faint)","stroke-width":String(12),opacity:"0.2"})),c>0&&h.appendChild(ot("circle",{cx:String(65),cy:String(65),r:String(46),fill:"none",stroke:"var(--af-green)","stroke-width":String(12),"stroke-dasharray":`${d} ${u}`,"stroke-dashoffset":String(l*.25),"stroke-linecap":"round"}));let p=ot("text",{x:String(65),y:String(61),"text-anchor":"middle","dominant-baseline":"middle","font-size":"24","font-weight":"700",fill:"var(--af-text-primary)"});p.textContent=`${Math.round(c*100)}%`,h.appendChild(p);let m=ot("text",{x:String(65),y:String(83),"text-anchor":"middle","font-size":"10",fill:"var(--af-text-muted)"});m.textContent=`${e}/${t} runs`,h.appendChild(m),a.appendChild(h)}function Yl(a,e){a.draggable=!0,a.addEventListener("dragstart",t=>{t.dataTransfer?.setData("text/plain",e),a.addClass("af-dragging")}),a.addEventListener("dragend",()=>{a.removeClass("af-dragging")})}function Kl(a,e){a.addEventListener("dragover",t=>{t.preventDefault(),a.addClass("af-drag-over")}),a.addEventListener("dragleave",t=>{let s=t.relatedTarget;(!s||!a.contains(s))&&a.removeClass("af-drag-over")}),a.addEventListener("drop",t=>{t.preventDefault();let s=t.dataTransfer?.getData("text/plain");a.removeClass("af-drag-over"),s&&e(s)})}var Fe=require("obsidian");function Ur(a){switch(a){case"connected":return"idle";case"connecting":case"reconnecting":return"pending";case"needs-auth":case"error":return"error";case"stopped":case"disabled":default:return"disabled"}}function $r(a,e,t){let{plugin:s}=e,n=s.runtime.getSnapshot(),r=s.channelCredentials.list(),i=a.createDiv({cls:"af-detail-header"}),o=i.createDiv({cls:"af-detail-header-left"});if(t){let S=s.channelManager?.getChannelStatus(t.name)??"disabled",j=o.createDiv({cls:`af-agent-card-avatar ${Ur(S)}`});(0,Fe.setIcon)(j,"radio");let ce=o.createDiv();ce.createDiv({cls:"af-detail-header-name",text:`Edit Channel: ${t.name}`}),ce.createDiv({cls:"af-detail-header-desc",text:`Status: ${S}`})}else{let S=o.createDiv({cls:"af-agent-card-avatar idle"});(0,Fe.setIcon)(S,"plus");let j=o.createDiv();j.createDiv({cls:"af-detail-header-name",text:"Create New Channel"}),j.createDiv({cls:"af-detail-header-desc",text:"Connect an external chat transport to an agent"})}i.createDiv({cls:"af-detail-header-actions"});let l={name:"",type:t?t.type:"slack",defaultAgent:t?t.defaultAgent:n.agents[0]?.name??"",allowedAgents:t?[...t.allowedAgents]:[],credentialRef:t?t.credentialRef:r[0]?.ref??"",allowedUsers:t?t.allowedUsers.join(` +`):"",perUserSessions:t?t.perUserSessions:!0,channelContext:t?t.channelContext:"",enabled:t?t.enabled:!0,tags:t?t.tags.join(", "):"",body:t?t.body:"",transportJson:t&&Object.keys(t.transport).length>0?JSON.stringify(t.transport,null,2):""},c=a.createDiv({cls:"af-create-form"}),d=c.createDiv({cls:"af-create-section"}),u=d.createDiv({cls:"af-create-section-header"}),h=u.createSpan({cls:"af-create-section-icon"});if((0,Fe.setIcon)(h,"radio"),u.createSpan({text:"Channel Details"}),t){let S=d.createDiv({cls:"af-form-row"});S.createDiv({cls:"af-form-label",text:"Name"}),S.createEl("input",{cls:"af-form-input",attr:{type:"text",value:t.name,disabled:"true"}}).setCssStyles({opacity:"0.6"})}else e.createFormField(d,"Name","my-slack","Unique identifier for this channel",S=>{l.name=S});let p=d.createDiv({cls:"af-form-row"});p.createDiv({cls:"af-form-label",text:"Type"});let m=p.createEl("select",{cls:"af-form-select"});for(let S of["slack","telegram","discord"]){let j=m.createEl("option",{text:S,attr:{value:S}});t&&S===t.type&&(j.selected=!0)}m.addEventListener("change",()=>{l.type=m.value});let f=d.createDiv({cls:"af-form-row"}),g=f.createDiv({cls:"af-form-label"});g.setText("Credential"),e.addTooltip(g,"Configured in Settings \u2192 Agent Fleet \u2192 Channel Credentials");let w=f.createEl("select",{cls:"af-form-select"});r.length===0&&w.createEl("option",{text:"(no credentials configured)",attr:{value:""}});for(let S of r){let j=w.createEl("option",{text:`${S.ref} (${S.entry.type})`,attr:{value:S.ref}});t&&S.ref===t.credentialRef&&(j.selected=!0)}w.addEventListener("change",()=>{l.credentialRef=w.value});let y=d.createDiv({cls:"af-form-row af-form-row-toggle"});y.createDiv({cls:"af-form-label",text:"Enabled"});let v=y.createDiv({cls:`af-agent-card-toggle${l.enabled?" on":""}`});v.onclick=()=>{let S=v.hasClass("on");v.toggleClass("on",!S),l.enabled=!S};let k=c.createDiv({cls:"af-create-section"}),b=k.createDiv({cls:"af-create-section-header"}),P=b.createSpan({cls:"af-create-section-icon"});(0,Fe.setIcon)(P,"bot"),b.createSpan({text:"Agent Routing"});let A=k.createDiv({cls:"af-form-row"}),M=A.createDiv({cls:"af-form-label"});M.setText("Default agent"),e.addTooltip(M,"Used when no @agent-name prefix is given in a message");let E=A.createEl("select",{cls:"af-form-select"});for(let S of n.agents){let j=E.createEl("option",{text:S.name,attr:{value:S.name}});t&&S.name===t.defaultAgent&&(j.selected=!0)}E.addEventListener("change",()=>{l.defaultAgent=E.value});let x=k.createDiv({cls:"af-form-row"}),_=x.createDiv({cls:"af-form-label"});_.setText("Allowed agents"),e.addTooltip(_,"Agents reachable via @prefix. Leave unchecked to allow all agents.");let R=x.createDiv({cls:"af-form-checkboxes"});for(let S of n.agents){let j=R.createEl("label",{cls:"af-form-checkbox-label"}),ce=j.createEl("input",{attr:{type:"checkbox",value:S.name}});t&&t.allowedAgents.includes(S.name)&&(ce.checked=!0),j.appendText(` ${S.name}`),ce.addEventListener("change",()=>{ce.checked?l.allowedAgents.includes(S.name)||l.allowedAgents.push(S.name):l.allowedAgents=l.allowedAgents.filter(Se=>Se!==S.name)})}let O=k.createDiv({cls:"af-form-row af-form-row-toggle"}),F=O.createDiv({cls:"af-form-label"});F.setText("Per-user sessions"),e.addTooltip(F,"Each external user gets their own isolated Claude session");let B=O.createDiv({cls:`af-agent-card-toggle${l.perUserSessions?" on":""}`});B.onclick=()=>{let S=B.hasClass("on");B.toggleClass("on",!S),l.perUserSessions=!S};let q=c.createDiv({cls:"af-create-section"}),oe=q.createDiv({cls:"af-create-section-header"}),be=oe.createSpan({cls:"af-create-section-icon"});(0,Fe.setIcon)(be,"shield-check"),oe.createSpan({text:"Access Control"});let ne=q.createDiv({cls:"af-form-label"});ne.setText("Allowed users"),e.addTooltip(ne,t?"Slack user IDs (U...), one per line. Only listed users can reach the bot.":"User IDs, one per line \u2014 Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot.");let Y=q.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`U0AQW6P37N1 +U0BXYZ12345`,rows:"4"}});t&&(Y.value=t.allowedUsers.join(` +`)),Y.addEventListener("input",()=>{l.allowedUsers=Y.value});let H=c.createDiv({cls:"af-create-section"}),X=H.createDiv({cls:"af-create-section-header"}),me=X.createSpan({cls:"af-create-section-icon"});(0,Fe.setIcon)(me,"message-square");let ee=X.createSpan({text:"Channel Context"});e.addTooltip(ee,"Extra instructions appended to the agent's system prompt when reached through this channel");let J=H.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"You are being contacted via Slack. Keep replies concise...",rows:"6"}});t&&(J.value=t.channelContext),J.addEventListener("input",()=>{l.channelContext=J.value}),e.createFormField(d,"Tags","ops, internal","Comma-separated metadata",S=>{l.tags=S},t?t.tags.join(", "):void 0);let K=c.createDiv({cls:"af-create-section"}),ke=K.createDiv({cls:"af-create-section-header"}),je=ke.createSpan({cls:"af-create-section-icon"});(0,Fe.setIcon)(je,"settings");let Qe=ke.createSpan({text:"Advanced"});e.addTooltip(Qe,"Markdown body (shown in the channel detail page) and transport-specific overrides");let Ze=K.createDiv({cls:"af-form-label"});Ze.setText("Body (markdown)"),t||e.addTooltip(Ze,"Free-form notes for this channel. Shown in the channel detail page; not sent to the agent.");let He=K.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Notes, runbook snippets, escalation contacts\u2026",rows:"4"}});t&&(He.value=t.body),He.addEventListener("input",()=>{l.body=He.value});let $=K.createDiv({cls:"af-form-label"});$.setText("Transport config (JSON)"),e.addTooltip($,t?"Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults.":"Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults.");let ae=K.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`{ "socket_mode": true -}`,rows:"4"}});Ue.addEventListener("input",()=>{d.transportJson=Ue.value});let Ne=s.createDiv({cls:"af-create-footer"}),q=Ne.createEl("button",{cls:"af-btn-sm",text:"Cancel"});q.onclick=()=>this.navigate("channels");let re=Ne.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(re,"plus","af-btn-icon"),re.appendText(" Create Channel"),re.onclick=async()=>{let j=d.name.trim();if(!j){new b.Notice("Channel name is required.");return}if(!d.credentialRef){new b.Notice("Select a credential.");return}let Ce;if(d.transportJson.trim())try{let ie=JSON.parse(d.transportJson);if(ie&&typeof ie=="object"&&!Array.isArray(ie))Ce=ie;else{new b.Notice("Transport config must be a JSON object.");return}}catch(ie){new b.Notice(`Transport JSON is invalid: ${ie instanceof Error?ie.message:String(ie)}`);return}let xe=ie=>ie.split(/[\n,]+/).map(I=>I.trim()).filter(Boolean),he=ie=>ie.split(",").map(I=>I.trim()).filter(Boolean),$e={name:oe(j),type:d.type,default_agent:d.defaultAgent,allowed_agents:d.allowedAgents.length>0?d.allowedAgents:void 0,enabled:d.enabled,credential_ref:d.credentialRef,allowed_users:xe(d.allowedUsers),per_user_sessions:d.perUserSessions,channel_context:d.channelContext.trim()||void 0,tags:he(d.tags).length>0?he(d.tags):void 0,transport:Ce};try{let ie=oe(j),I=await this.plugin.repository.getAvailablePath(this.plugin.repository.getSubfolder("channels"),ie);await this.plugin.app.vault.create(I,H($e,d.body.trim())),new b.Notice(`Channel "${ie}" created.`),await this.plugin.refreshFromVault(),this.navigate("edit-channel",ie)}catch(ie){let I=ie instanceof Error?ie.message:String(ie);new b.Notice(`Failed to create channel: ${I}`)}}}renderEditChannelPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"radio","No channel selected","");return}let a=this.plugin.runtime.getSnapshot().channels.find(I=>I.name===n);if(!a){this.renderEmptyState(s,"radio","Channel not found",`Channel "${n}" was not found`);return}let i=this.plugin.runtime.getSnapshot(),o=this.plugin.channelCredentials.list(),c=this.plugin.channelManager?.getChannelStatus(a.name)??"disabled",l=s.createDiv({cls:"af-detail-header"}),h=l.createDiv({cls:"af-detail-header-left"}),d=h.createDiv({cls:`af-agent-card-avatar ${Qo(c)}`});(0,b.setIcon)(d,"radio");let u=h.createDiv();u.createDiv({cls:"af-detail-header-name",text:`Edit Channel: ${a.name}`}),u.createDiv({cls:"af-detail-header-desc",text:`Status: ${c}`});let p=l.createDiv({cls:"af-detail-header-actions"}),m={type:a.type,defaultAgent:a.defaultAgent,allowedAgents:[...a.allowedAgents],credentialRef:a.credentialRef,allowedUsers:a.allowedUsers.join(` -`),perUserSessions:a.perUserSessions,channelContext:a.channelContext,enabled:a.enabled,tags:a.tags.join(", "),body:a.body,transportJson:Object.keys(a.transport).length>0?JSON.stringify(a.transport,null,2):""},f=s.createDiv({cls:"af-create-form"}),g=f.createDiv({cls:"af-create-section"}),y=g.createDiv({cls:"af-create-section-header"}),v=y.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(v,"radio"),y.createSpan({text:"Channel Details"});let k=g.createDiv({cls:"af-form-row"});k.createDiv({cls:"af-form-label",text:"Name"}),k.createEl("input",{cls:"af-form-input",attr:{type:"text",value:a.name,disabled:"true"}}).setCssStyles({opacity:"0.6"});let S=g.createDiv({cls:"af-form-row"});S.createDiv({cls:"af-form-label",text:"Type"});let T=S.createEl("select",{cls:"af-form-select"});for(let I of["slack","telegram","discord"]){let se=T.createEl("option",{text:I,attr:{value:I}});I===a.type&&(se.selected=!0)}T.addEventListener("change",()=>{m.type=T.value});let _=g.createDiv({cls:"af-form-row"}),D=_.createDiv({cls:"af-form-label"});D.setText("Credential"),this.addTooltip(D,"Configured in Settings \u2192 Agent Fleet \u2192 Channel Credentials");let O=_.createEl("select",{cls:"af-form-select"});o.length===0&&O.createEl("option",{text:"(no credentials configured)",attr:{value:""}});for(let I of o){let se=O.createEl("option",{text:`${I.ref} (${I.entry.type})`,attr:{value:I.ref}});I.ref===a.credentialRef&&(se.selected=!0)}O.addEventListener("change",()=>{m.credentialRef=O.value});let C=g.createDiv({cls:"af-form-row af-form-row-toggle"});C.createDiv({cls:"af-form-label",text:"Enabled"});let E=C.createDiv({cls:`af-agent-card-toggle${a.enabled?" on":""}`});E.onclick=()=>{let I=E.hasClass("on");E.toggleClass("on",!I),m.enabled=!I};let P=f.createDiv({cls:"af-create-section"}),N=P.createDiv({cls:"af-create-section-header"}),M=N.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(M,"bot"),N.createSpan({text:"Agent Routing"});let U=P.createDiv({cls:"af-form-row"}),$=U.createDiv({cls:"af-form-label"});$.setText("Default agent"),this.addTooltip($,"Used when no @agent-name prefix is given in a message");let Z=U.createEl("select",{cls:"af-form-select"});for(let I of i.agents){let se=Z.createEl("option",{text:I.name,attr:{value:I.name}});I.name===a.defaultAgent&&(se.selected=!0)}Z.addEventListener("change",()=>{m.defaultAgent=Z.value});let de=P.createDiv({cls:"af-form-row"}),me=de.createDiv({cls:"af-form-label"});me.setText("Allowed agents"),this.addTooltip(me,"Agents reachable via @prefix. Leave unchecked to allow all agents.");let ge=de.createDiv({cls:"af-form-checkboxes"});for(let I of i.agents){let se=ge.createEl("label",{cls:"af-form-checkbox-label"}),ce=se.createEl("input",{attr:{type:"checkbox",value:I.name}});a.allowedAgents.includes(I.name)&&(ce.checked=!0),se.appendText(` ${I.name}`),ce.addEventListener("change",()=>{ce.checked?m.allowedAgents.includes(I.name)||m.allowedAgents.push(I.name):m.allowedAgents=m.allowedAgents.filter(te=>te!==I.name)})}let X=P.createDiv({cls:"af-form-row af-form-row-toggle"}),W=X.createDiv({cls:"af-form-label"});W.setText("Per-user sessions"),this.addTooltip(W,"Each external user gets their own isolated Claude session");let K=X.createDiv({cls:`af-agent-card-toggle${a.perUserSessions?" on":""}`});K.onclick=()=>{let I=K.hasClass("on");K.toggleClass("on",!I),m.perUserSessions=!I};let ne=f.createDiv({cls:"af-create-section"}),G=ne.createDiv({cls:"af-create-section-header"}),ee=G.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(ee,"shield-check"),G.createSpan({text:"Access Control"});let Q=ne.createDiv({cls:"af-form-label"});Q.setText("Allowed users"),this.addTooltip(Q,"Slack user IDs (U...), one per line. Only listed users can reach the bot.");let ae=ne.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`U0AQW6P37N1 -U0BXYZ12345`,rows:"4"}});ae.value=a.allowedUsers.join(` -`),ae.addEventListener("input",()=>{m.allowedUsers=ae.value});let ve=f.createDiv({cls:"af-create-section"}),Pe=ve.createDiv({cls:"af-create-section-header"}),Ie=Pe.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Ie,"message-square");let Me=Pe.createSpan({text:"Channel Context"});this.addTooltip(Me,"Extra instructions appended to the agent's system prompt when reached through this channel");let Fe=ve.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"You are being contacted via Slack. Keep replies concise...",rows:"6"}});Fe.value=a.channelContext,Fe.addEventListener("input",()=>{m.channelContext=Fe.value}),this.createFormField(g,"Tags","ops, internal","Comma-separated metadata",I=>{m.tags=I},a.tags.join(", "));let ke=f.createDiv({cls:"af-create-section"}),Oe=ke.createDiv({cls:"af-create-section-header"}),Ue=Oe.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Ue,"settings");let Ne=Oe.createSpan({text:"Advanced"});this.addTooltip(Ne,"Markdown body (shown in the channel detail page) and transport-specific overrides"),ke.createDiv({cls:"af-form-label"}).setText("Body (markdown)");let re=ke.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Notes, runbook snippets, escalation contacts\u2026",rows:"4"}});re.value=a.body,re.addEventListener("input",()=>{m.body=re.value});let j=ke.createDiv({cls:"af-form-label"});j.setText("Transport config (JSON)"),this.addTooltip(j,"Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults.");let Ce=ke.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`{ - "socket_mode": true -}`,rows:"4"}});Ce.value=m.transportJson,Ce.addEventListener("input",()=>{m.transportJson=Ce.value});let xe=s.createDiv({cls:"af-create-footer"}),he=xe.createEl("button",{cls:"af-btn-sm danger"});R(he,"trash-2","af-btn-icon"),he.appendText(" Delete"),he.onclick=async()=>{await this.plugin.repository.deleteChannel(a.name),new b.Notice(`Channel "${a.name}" deleted.`),await new Promise(I=>window.setTimeout(I,200)),await this.plugin.refreshFromVault(),this.navigate("channels")},xe.createDiv({cls:"af-toolbar-spacer"});let $e=xe.createEl("button",{cls:"af-btn-sm",text:"Cancel"});$e.onclick=()=>this.navigate("channels");let ie=xe.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(ie,"check","af-btn-icon"),ie.appendText(" Save Changes"),ie.onclick=async()=>{let I=te=>te.split(/[\n,]+/).map(st=>st.trim()).filter(Boolean),se=te=>te.split(",").map(st=>st.trim()).filter(Boolean),ce;if(m.transportJson.trim())try{let te=JSON.parse(m.transportJson);if(te&&typeof te=="object"&&!Array.isArray(te))ce=te;else{new b.Notice("Transport config must be a JSON object.");return}}catch(te){new b.Notice(`Transport JSON is invalid: ${te instanceof Error?te.message:String(te)}`);return}else ce={};try{await this.plugin.repository.updateChannel(a.name,{type:m.type,default_agent:m.defaultAgent,allowed_agents:m.allowedAgents.length>0?m.allowedAgents:[],enabled:m.enabled,credential_ref:m.credentialRef,allowed_users:I(m.allowedUsers),per_user_sessions:m.perUserSessions,channel_context:m.channelContext.trim(),tags:se(m.tags),body:m.body.trim(),transport:ce}),new b.Notice(`Channel "${a.name}" updated.`),await this.plugin.refreshFromVault(),this.navigate("channels")}catch(te){let st=te instanceof Error?te.message:String(te);new b.Notice(`Failed to update channel: ${st}`)}}}renderApprovalsPage(e){let s=e.createDiv({cls:"af-approvals-page"}),n=this.plugin.runtime.getRecentRuns(),a=s.createDiv({cls:"af-agents-toolbar"});a.createDiv({cls:"af-page-title",text:"Approvals"}),a.createDiv({cls:"af-toolbar-spacer"});let i=n.filter(c=>(c.approvals??[]).some(l=>l.status==="pending"));if(i.length>0){let c=s.createDiv({cls:"af-section-card"}),h=c.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(h,"alert-triangle"),h.appendText(` Pending (${i.length})`);let d=c.createDiv({cls:"af-approvals-list"});for(let u of i)this.renderApprovalItem(d,u,!0)}else{let c=s.createDiv({cls:"af-section-card"});this.renderEmptyState(c,"shield-check","No pending approvals","All clear!")}let o=n.filter(c=>(c.approvals??[]).some(l=>l.status!=="pending"));if(o.length>0){let c=s.createDiv({cls:"af-section-card"}),h=c.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(h,"check-circle-2"),h.appendText(" History");let d=c.createDiv({cls:"af-approvals-list"});for(let u of o.slice(0,20))this.renderApprovalItem(d,u,!1)}}renderApprovalItem(e,s,n){for(let a of s.approvals??[]){if(n&&a.status!=="pending"||!n&&a.status==="pending")continue;let i=e.createDiv({cls:"af-approval-item"}),o=i.createDiv({cls:"af-approval-item-icon"});a.status==="pending"?((0,b.setIcon)(o,"shield-check"),o.addClass("pending")):a.status==="approved"?((0,b.setIcon)(o,"check-circle-2"),o.addClass("approved")):((0,b.setIcon)(o,"x-circle"),o.addClass("rejected"));let c=i.createDiv({cls:"af-approval-item-body"});if(c.createDiv({cls:"af-approval-item-title",text:`${s.agent} \u2192 ${a.tool}`}),c.createDiv({cls:"af-approval-item-meta",text:`Task: ${s.task} \xB7 ${a.command??"no command"} \xB7 ${this.formatStarted(s.started)}`}),a.reason&&c.createDiv({cls:"af-approval-item-reason",text:`Reason: ${a.reason}`}),n&&a.status==="pending"){let l=i.createDiv({cls:"af-approval-item-actions"}),h=l.createEl("button",{cls:"af-btn-approve"});R(h,"check-circle-2","af-btn-icon"),h.appendText(" Approve"),h.onclick=()=>void this.plugin.runtime.resolveApproval(s,a.tool,"approved").then(()=>this.render());let d=l.createEl("button",{cls:"af-btn-reject"});R(d,"x-circle","af-btn-icon"),d.appendText(" Reject"),d.onclick=()=>void this.plugin.runtime.resolveApproval(s,a.tool,"rejected").then(()=>this.render())}}}renderTaskDetailPage(e){let s=e.createDiv({cls:"af-task-detail-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"circle-dot","No task selected","");return}let a=this.plugin.runtime.getSnapshot().tasks.find(P=>P.taskId===n);if(!a){this.renderEmptyState(s,"circle-dot","Task not found",`Task "${n}" was not found`);return}let i=this.plugin.runtime.getSnapshot(),o=this.plugin.runtime.getRecentRuns().filter(P=>P.task===n),c=i.agents.find(P=>P.name===a.agent),l=s.createDiv({cls:"af-detail-header"}),h=l.createDiv({cls:"af-detail-header-left"}),d=h.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(d,"circle-dot");let u=h.createDiv();u.createDiv({cls:"af-detail-header-name",text:a.taskId}),u.createDiv({cls:"af-detail-header-desc",text:`Agent: ${a.agent}`});let p=l.createDiv({cls:"af-detail-header-actions"}),m=p.createEl("button",{cls:"af-btn-sm"});R(m,"edit","af-btn-icon"),m.appendText(" Edit"),m.onclick=()=>this.navigate("edit-task",a.taskId);let f=p.createEl("button",{cls:"af-btn-sm primary"});R(f,"play","af-btn-icon"),f.appendText(" Run Now"),f.onclick=()=>void this.plugin.runtime.runTaskNow(a);let g=s.createDiv({cls:"af-section-card"}),v=g.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(v,"file-text"),v.appendText(" Details");let k=g.createDiv({cls:"af-config-form"});this.renderConfigRow(k,"Agent",a.agent),this.renderConfigRow(k,"Priority",a.priority.charAt(0).toUpperCase()+a.priority.slice(1)),this.renderConfigRow(k,"Status",a.enabled?"Enabled":"Disabled");let w=a.schedule?this.humanizeCron(a.schedule):a.runAt??"Manual (run on demand)";this.renderConfigRow(k,"Schedule",w),a.schedule&&this.renderConfigRow(k,"Catch up if missed",a.catchUp?"Yes":"No"),this.renderConfigRow(k,"Created",a.created),this.renderConfigRow(k,"Runs",String(a.runCount)),a.lastRun&&this.renderConfigRow(k,"Last Run",this.formatStarted(a.lastRun));let S=s.createDiv({cls:"af-section-card"}),_=S.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(_,"message-square"),_.appendText(" Instructions"),S.createDiv({cls:"af-output-block",text:a.body||"(empty)"});let D=s.createDiv({cls:"af-section-card"}),C=D.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});R(C,"scroll-text"),C.appendText(" Recent Runs");let E=D.createDiv({cls:"af-timeline"});if(o.length===0)this.renderEmptyState(E,"scroll-text","No runs yet","");else for(let P of o.slice(0,10))this.renderTimelineItem(E,P)}handleSearch(e,s){if(s.querySelector(".af-search-results")?.remove(),e.length<2)return;let n=e.toLowerCase(),a=this.plugin.runtime.getSnapshot(),i=this.plugin.runtime.getRecentRuns(),o=[];for(let l of a.agents)(l.name.toLowerCase().includes(n)||(l.description?.toLowerCase().includes(n)??!1))&&o.push({label:`Agent: ${l.name}`,icon:"bot",action:()=>this.navigate("agent-detail",l.name)});for(let l of a.tasks)(l.taskId.toLowerCase().includes(n)||l.body.toLowerCase().includes(n))&&o.push({label:`Task: ${l.taskId}`,icon:"circle-dot",action:()=>this.navigate("task-detail",l.taskId)});for(let l of a.skills)l.name.toLowerCase().includes(n)&&o.push({label:`Skill: ${l.name}`,icon:"puzzle",action:()=>this.navigate("edit-skill",l.name)});for(let l of i.slice(0,20))l.output.toLowerCase().includes(n)&&o.push({label:`Run: ${l.agent} / ${l.task}`,icon:"scroll-text",action:()=>this.openSlideover(l)});if(o.length===0)return;let c=s.createDiv({cls:"af-search-results"});for(let l of o.slice(0,10)){let h=c.createDiv({cls:"af-search-result-item"});R(h,l.icon,"af-search-result-icon"),h.createSpan({text:l.label}),h.onclick=()=>{c.remove(),l.action()}}}openChatSlideover(e){this.plugin.openChatView(e.name)}openSlideover(e){this.contentEl.querySelector(".af-slideover-overlay")?.remove();let s=this.contentEl.createDiv({cls:"af-slideover-overlay"}),n=s.createDiv({cls:"af-slideover"}),a=n.createDiv({cls:"af-slideover-header"});a.createDiv({cls:"af-slideover-title",text:"Run Details"});let i=a.createEl("button",{cls:"clickable-icon"});(0,b.setIcon)(i,"cross"),i.onclick=()=>s.remove();let o=n.createDiv({cls:"af-slideover-body"}),c=o.createDiv({cls:"af-slideover-section"});c.createDiv({cls:"af-slideover-section-title",text:"METADATA"}),this.renderDetailRow(c,"Run ID",e.runId.slice(0,8)),this.renderDetailRow(c,"Agent",e.agent),this.renderDetailRow(c,"Task",e.task);let l=c.createDiv({cls:"af-detail-row"});l.createSpan({cls:"af-detail-label",text:"Status"});let d=l.createSpan({cls:"af-detail-value"}).createSpan({cls:`af-status-badge ${this.statusToBadgeClass(e.status)}`}),u=d.createSpan();(0,b.setIcon)(u,this.statusToIconName(e.status)),d.appendText(` ${this.statusToBadgeText(e.status)}`),this.renderDetailRow(c,"Started",e.started),this.renderDetailRow(c,"Duration",this.formatDuration(e.durationSeconds)),this.renderDetailRow(c,"Tokens",e.tokensUsed?e.tokensUsed.toLocaleString():"\u2014");{let w={task:"from task override",agent:"from agent",settings:"from settings default","cli-default":"CLI default"},S=e.modelSource?` (${w[e.modelSource]??e.modelSource})`:"",T=e.concreteModel&&e.concreteModel!==e.model?` \u2192 ${e.concreteModel}`:"";this.renderDetailRow(c,"Model",`${e.model}${T}${S}`)}let p=5e4,m=w=>w.length>p?w.slice(0,p)+` - ---- -*Truncated (${(w.length/1024).toFixed(0)} KB total). Open the run note for full content.*`:w,f=!!(e.finalResult&&e.finalResult.trim()),g=e.output?.trim()??"",y=f&&g.length>0&&g!==e.finalResult.trim();if(f){let w=o.createDiv({cls:"af-slideover-section"});w.createDiv({cls:"af-slideover-section-title",text:"OUTPUT"});let S=w.createDiv({cls:"af-output-block af-compact-md"});if(b.MarkdownRenderer.render(this.app,m(e.finalResult),S,"",this),y){let T=w.createEl("details",{cls:"af-run-transcript"}),_=T.createEl("summary");(0,b.setIcon)(_.createSpan({cls:"af-run-transcript-icon"}),"file-text"),_.createSpan({text:"Show full transcript"}),_.createSpan({cls:"af-run-transcript-meta"}).setText(`${(g.length/1024).toFixed(1)} KB`);let O=T.createDiv({cls:"af-output-block af-compact-md af-run-transcript-body"});b.MarkdownRenderer.render(this.app,m(g),O,"",this)}}else if(g){let w=o.createDiv({cls:"af-slideover-section"});w.createDiv({cls:"af-slideover-section-title",text:"OUTPUT"});let S=w.createDiv({cls:"af-output-block af-compact-md"});b.MarkdownRenderer.render(this.app,m(g),S,"",this)}if(e.toolsUsed.length>0){let w=o.createDiv({cls:"af-slideover-section"});w.createDiv({cls:"af-slideover-section-title",text:"TOOLS USED"}),w.createDiv({cls:"af-output-block",text:e.toolsUsed.join(` -`)})}let v=o.createDiv({cls:"af-slideover-actions"});if(e.filePath){let w=v.createEl("button",{cls:"af-btn-sm"});R(w,"external-link","af-btn-icon"),w.appendText(" Open Run Note"),w.onclick=()=>void this.plugin.openPath(e.filePath)}let k=v.createEl("button",{cls:"af-btn-sm primary"});R(k,"refresh-cw","af-btn-icon"),k.appendText(" Re-run Task"),k.onclick=()=>void this.plugin.runAgentPrompt(e.agent),s.onclick=w=>{w.target===s&&s.remove()}}renderDetailRow(e,s,n){let a=e.createDiv({cls:"af-detail-row"});a.createSpan({cls:"af-detail-label",text:s}),a.createSpan({cls:"af-detail-value af-mono",text:n})}renderEmptyState(e,s,n,a){let i=e.createDiv({cls:"af-empty-state"}),o=i.createDiv({cls:"af-empty-icon"});(0,b.setIcon)(o,s),i.createDiv({cls:"af-empty-label",text:n}),a&&i.createDiv({cls:"af-empty-sublabel",text:a})}healthToClass(e){switch(e){case"running":return"running";case"error":return"error";case"pending":return"pending";case"disabled":return"disabled";default:return"idle"}}statusToTimelineClass(e){switch(e){case"success":return"success";case"failure":case"timeout":return"error";case"cancelled":return"warning";case"pending_approval":return"pending";default:return"running"}}statusToIconName(e){switch(e){case"success":return"check-circle-2";case"failure":return"x-circle";case"timeout":return"clock";case"pending_approval":return"shield-check";case"cancelled":return"square";default:return"loader-2"}}statusToBadgeClass(e){switch(e){case"success":return"success";case"failure":return"failure";case"timeout":return"timeout";case"pending_approval":return"pending";case"cancelled":return"cancelled";default:return"running"}}statusToBadgeText(e){switch(e){case"success":return"Success";case"failure":return"Failed";case"timeout":return"Timeout";case"pending_approval":return"Pending";case"cancelled":return"Cancelled";case"interrupted":return"Interrupted";default:return e}}formatDuration(e){if(e<60)return`${e}s`;let s=Math.floor(e/60),n=e%60;return n>0?`${s}m ${n}s`:`${s}m`}formatStarted(e){try{let s=new Date(e),n=new Date;if(s.toDateString()===n.toDateString())return s.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});let a=new Date(n);return a.setDate(a.getDate()-1),s.toDateString()===a.toDateString()?`Yesterday ${s.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`:s.toLocaleDateString([],{month:"short",day:"numeric"})+` ${s.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}catch{return e}}formatNextRun(e){try{let s=new Date(e),n=new Date,a=s.getTime()-n.getTime();if(a<0)return"overdue";let i=Math.round(a/6e4);if(i<60)return`${i}m`;let o=Math.round(i/60);return o<24?`${o}h`:s.toLocaleDateString([],{month:"short",day:"numeric"})}catch{return e}}getNextTaskLabel(e){let n=e.map(i=>i.nextRun).filter(Boolean).sort()[0];return n?`${e.find(i=>i.nextRun===n)?.agent??"unknown"} in ${this.formatNextRun(n)}`:"none"}getInitials(e){return e.split("-").map(s=>s[0]?.toUpperCase()??"").slice(0,2).join("")}renderAgentAvatar(e,s){let n=s.avatar?.trim();if(!n){(0,b.setIcon)(e,"bot");return}/^[a-z][a-z0-9-]*$/.test(n)?(0,b.setIcon)(e,n):e.setText(n)}getSkillIcon(e){return e.includes("git")?"settings":e.includes("summarize")||e.includes("log")?"activity":e.includes("review")||e.includes("check")?"check-circle-2":e.includes("vault")||e.includes("note")?"file-text":"puzzle"}renderInlineSchedule(e,s){let n=this.parseCronComponents(s.schedule),a=e.createDiv({cls:"af-form-row"});a.createDiv({cls:"af-form-label",text:"Frequency"});let i=a.createEl("select",{cls:"af-form-select"}),o=[["every_5m","Every 5 minutes"],["every_15m","Every 15 minutes"],["every_30m","Every 30 minutes"],["every_hour","Every hour"],["every_2h","Every 2 hours"],["daily","Daily"],["weekdays","Weekdays"],["weekly","Weekly"],["monthly","Monthly"]];for(let[w,S]of o){let T=i.createEl("option",{text:S,attr:{value:w}});w===n.freq&&(T.selected=!0)}let c=e.createDiv({cls:"af-form-row af-schedule-time-row"});c.createDiv({cls:"af-form-label",text:"Time"});let l=c.createDiv({cls:"af-schedule-time-selects"}),h=l.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let w=0;w<24;w++){let S=w>=12?"PM":"AM",T=w===0?12:w>12?w-12:w,_=h.createEl("option",{text:`${T} ${S}`,attr:{value:String(w)}});w===n.hour&&(_.selected=!0)}l.createSpan({cls:"af-schedule-colon",text:":"});let d=l.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let w=0;w<60;w+=5){let S=d.createEl("option",{text:String(w).padStart(2,"0"),attr:{value:String(w)}});w===n.minute&&(S.selected=!0)}let u=e.createDiv({cls:"af-form-row af-schedule-day-row"});u.createDiv({cls:"af-form-label",text:"Day"});let p=u.createDiv({cls:"af-schedule-day-buttons"}),m=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],f=new Set(n.days);for(let w=0;w<7;w++){let S=p.createEl("button",{cls:`af-schedule-day-btn${f.has(w)?" active":""}`,text:m[w]});S.onclick=()=>{f.has(w)?f.delete(w):f.add(w),S.toggleClass("active",f.has(w)),k()}}let g=e.createDiv({cls:"af-form-row af-schedule-dom-row"});g.createDiv({cls:"af-form-label",text:"Day of month"});let y=g.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let w=1;w<=28;w++){let S=y.createEl("option",{text:String(w),attr:{value:String(w)}});w===n.dayOfMonth&&(S.selected=!0)}let v=()=>{let w=i.value,S=["daily","weekdays","weekly","monthly"].includes(w),T=w==="weekly",_=w==="monthly";c.setCssStyles({display:S?"":"none"}),u.setCssStyles({display:T?"":"none"}),g.setCssStyles({display:_?"":"none"})},k=()=>{let w=i.value,S=h.value,T=d.value,_="";switch(w){case"every_5m":_="*/5 * * * *";break;case"every_15m":_="*/15 * * * *";break;case"every_30m":_="*/30 * * * *";break;case"every_hour":_="0 * * * *";break;case"every_2h":_="0 */2 * * *";break;case"daily":_=`${T} ${S} * * *`;break;case"weekdays":_=`${T} ${S} * * 1-5`;break;case"weekly":{let D=Array.from(f).sort().join(",")||"1";_=`${T} ${S} * * ${D}`;break}case"monthly":_=`${T} ${S} ${y.value} * *`;break}s.schedule=_,s.type="recurring"};i.addEventListener("change",()=>{v(),k()}),h.addEventListener("change",k),d.addEventListener("change",k),y.addEventListener("change",k),v()}renderHeartbeatSchedule(e,s){let n=this.parseCronComponents(s.heartbeatSchedule),a=e.createDiv({cls:"af-form-row"});a.createDiv({cls:"af-form-label",text:"Frequency"});let i=a.createEl("select",{cls:"af-form-select"}),o=[["every_5m","Every 5 minutes"],["every_15m","Every 15 minutes"],["every_30m","Every 30 minutes"],["every_hour","Every hour"],["every_2h","Every 2 hours"],["every_4h","Every 4 hours"],["every_6h","Every 6 hours"],["every_12h","Every 12 hours"],["daily","Once a day"]],c="every_hour",l={"*/5 * * * *":"every_5m","*/15 * * * *":"every_15m","*/30 * * * *":"every_30m","0 * * * *":"every_hour","0 */2 * * *":"every_2h","0 */4 * * *":"every_4h","0 */6 * * *":"every_6h","0 */12 * * *":"every_12h"};l[s.heartbeatSchedule]?c=l[s.heartbeatSchedule]:(n.freq==="daily"||n.freq==="weekdays")&&(c="daily");for(let[g,y]of o){let v=i.createEl("option",{text:y,attr:{value:g}});g===c&&(v.selected=!0)}let h=e.createDiv({cls:"af-form-row af-schedule-time-row"});h.createDiv({cls:"af-form-label",text:"Time"});let d=h.createDiv({cls:"af-schedule-time-selects"}),u=d.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let g=0;g<24;g++){let y=g>=12?"PM":"AM",v=g===0?12:g>12?g-12:g,k=u.createEl("option",{text:`${v} ${y}`,attr:{value:String(g)}});g===n.hour&&(k.selected=!0)}d.createSpan({cls:"af-schedule-colon",text:":"});let p=d.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let g=0;g<60;g+=5){let y=p.createEl("option",{text:String(g).padStart(2,"0"),attr:{value:String(g)}});g===n.minute&&(y.selected=!0)}let m=()=>{h.setCssStyles({display:i.value==="daily"?"":"none"})},f=()=>{let g=i.value,y=u.value,v=p.value;switch(g){case"every_5m":s.heartbeatSchedule="*/5 * * * *";break;case"every_15m":s.heartbeatSchedule="*/15 * * * *";break;case"every_30m":s.heartbeatSchedule="*/30 * * * *";break;case"every_hour":s.heartbeatSchedule="0 * * * *";break;case"every_2h":s.heartbeatSchedule="0 */2 * * *";break;case"every_4h":s.heartbeatSchedule="0 */4 * * *";break;case"every_6h":s.heartbeatSchedule="0 */6 * * *";break;case"every_12h":s.heartbeatSchedule="0 */12 * * *";break;case"daily":s.heartbeatSchedule=`${v} ${y} * * *`;break}};i.addEventListener("change",()=>{m(),f()}),u.addEventListener("change",f),p.addEventListener("change",f),m()}parseCronComponents(e){let s={freq:"daily",hour:9,minute:0,days:[1],dayOfMonth:1};if(!e?.trim())return s;let n={"*/5 * * * *":"every_5m","*/15 * * * *":"every_15m","*/30 * * * *":"every_30m","0 * * * *":"every_hour","0 */2 * * *":"every_2h"};if(n[e])return{...s,freq:n[e]};let a=e.trim().split(/\s+/);if(a.length!==5)return s;let[i,o,c,,l]=a,h=parseInt(o??"9",10),d=parseInt(i??"0",10);if(c==="*"&&l==="*")return{...s,freq:"daily",hour:h,minute:d};if(c==="*"&&l==="1-5")return{...s,freq:"weekdays",hour:h,minute:d};if(c==="*"&&l!=="*"){let u=(l??"1").split(",").map(p=>parseInt(p,10));return{...s,freq:"weekly",hour:h,minute:d,days:u}}return l==="*"&&c!=="*"?{...s,freq:"monthly",hour:h,minute:d,dayOfMonth:parseInt(c??"1",10)}:{...s,hour:h,minute:d}}humanizeCron(e){let s={"*/5 * * * *":"Every 5 minutes","*/10 * * * *":"Every 10 minutes","*/15 * * * *":"Every 15 minutes","*/30 * * * *":"Every 30 minutes","0 * * * *":"Every hour","0 */2 * * *":"Every 2 hours"};if(s[e])return s[e];let n=e.toLowerCase().trim();if(n.startsWith("every ")||n.startsWith("daily ")||n==="daily")return e;if(n.startsWith("hourly"))return"Every hour";if(n.startsWith("weekdays")||n.startsWith("weekly")||n.startsWith("monthly"))return e;let a=e.trim().split(/\s+/);if(a.length!==5)return e;let[i,o,c,,l]=a,h=(p,m)=>{let f=parseInt(p??"0",10),g=parseInt(m??"0",10),y=f>=12?"PM":"AM",v=f===0?12:f>12?f-12:f;return g===0?`${v} ${y}`:`${v}:${String(g).padStart(2,"0")} ${y}`},d=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],u=p=>p==="1-5"?"weekdays":p==="0,6"?"weekends":p.split(",").map(f=>parseInt(f,10)).map(f=>d[f]??f).join(", ");return o==="*"&&c==="*"&&l==="*"?i==="*"?"Every minute":`Every hour at :${String(i).padStart(2,"0")}`:c==="*"&&l==="*"&&o!=="*"?`Daily at ${h(o??"0",i??"0")}`:c==="*"&&l==="1-5"&&o!=="*"?`Weekdays at ${h(o??"0",i??"0")}`:c==="*"&&l!=="*"&&o!=="*"?`${u(l??"1")} at ${h(o??"0",i??"0")}`:l==="*"&&c!=="*"&&o!=="*"?`Monthly on the ${c} at ${h(o??"0",i??"0")}`:e}getTagClass(e){return e==="monitoring"?"monitoring":e==="devops"?"devops":e==="sample"?"sample":"default"}renderCreateAgentPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=s.createDiv({cls:"af-detail-header"}),a=n.createDiv({cls:"af-detail-header-left"}),i=a.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(i,"plus");let o=a.createDiv();o.createDiv({cls:"af-detail-header-name",text:"Create New Agent"}),o.createDiv({cls:"af-detail-header-desc",text:"Configure a new agent for your fleet"});let c=n.createDiv({cls:"af-detail-header-actions"}),l={name:"",description:"",avatar:"",tags:"",systemPrompt:"",model:"default",adapter:"claude-code",cwd:"",timeout:300,permissionMode:"bypassPermissions",effort:"",selectedSkills:new Set,selectedMcpServers:new Set,skillsBody:"",contextBody:"",approvalRequired:"",memory:!0,enabled:!0,allowedCommands:"",blockedCommands:"",heartbeatEnabled:!1,heartbeatSchedule:"0 */6 * * *",heartbeatBody:"",heartbeatNotify:!0,heartbeatChannel:"",heartbeatChannelTarget:"",autoCompactThreshold:85,wikiReferences:[]},h={none:{label:"None",prompt:""},coding:{label:"Coding Agent",prompt:`You are a coding agent. Review code, write tests, fix bugs, and implement features. +}`,rows:"4"}});t&&(ae.value=l.transportJson),ae.addEventListener("input",()=>{l.transportJson=ae.value});let ge=a.createDiv({cls:"af-create-footer"});if(t){let S=ge.createEl("button",{cls:"af-btn-sm danger"});T(S,"trash-2","af-btn-icon"),S.appendText(" Delete"),S.onclick=()=>{new ut(s.app,{title:`Delete channel "${t.name}"?`,body:"The channel file will be moved to your system trash and can be recovered.",confirmText:"Delete",danger:!0,onConfirm:async()=>{await s.repository.deleteChannel(t.name),new Fe.Notice(`Channel "${t.name}" deleted.`),await new Promise(j=>window.setTimeout(j,200)),await s.refreshFromVault(),e.navigate("channels")}}).open()},ge.createDiv({cls:"af-toolbar-spacer"})}let et=ge.createEl("button",{cls:"af-btn-sm",text:"Cancel"});et.onclick=()=>e.navigate("channels");let xe=ge.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(xe,t?"check":"plus","af-btn-icon"),xe.appendText(t?" Save Changes":" Create Channel");let Ke=S=>S.split(/[\n,]+/).map(j=>j.trim()).filter(Boolean),_e=S=>S.split(",").map(j=>j.trim()).filter(Boolean),Le=()=>{if(l.transportJson.trim())try{let S=JSON.parse(l.transportJson);return S&&typeof S=="object"&&!Array.isArray(S)?S:(new Fe.Notice("Transport config must be a JSON object."),null)}catch(S){return new Fe.Notice(`Transport JSON is invalid: ${S instanceof Error?S.message:String(S)}`),null}};t?xe.onclick=async()=>{let S=Le();if(S===null)return;S===void 0&&(S={});let j=e.markSubmitBusy(xe,"Saving...","check","Save Changes");try{await s.repository.updateChannel(t.name,{type:l.type,default_agent:l.defaultAgent,allowed_agents:l.allowedAgents.length>0?l.allowedAgents:[],enabled:l.enabled,credential_ref:l.credentialRef,allowed_users:Ke(l.allowedUsers),per_user_sessions:l.perUserSessions,channel_context:l.channelContext.trim(),tags:_e(l.tags),body:l.body.trim(),transport:S}),new Fe.Notice(`Channel "${t.name}" updated.`),await s.refreshFromVault(),e.navigate("channels")}catch(ce){let Se=ce instanceof Error?ce.message:String(ce);new Fe.Notice(`Failed to update channel: ${Se}`),j()}}:xe.onclick=async()=>{let S=l.name.trim();if(!S){new Fe.Notice("Channel name is required.");return}if(!l.credentialRef){new Fe.Notice("Select a credential.");return}let j=Le();if(j===null)return;let ce={name:z(S),type:l.type,default_agent:l.defaultAgent,allowed_agents:l.allowedAgents.length>0?l.allowedAgents:void 0,enabled:l.enabled,credential_ref:l.credentialRef,allowed_users:Ke(l.allowedUsers),per_user_sessions:l.perUserSessions,channel_context:l.channelContext.trim()||void 0,tags:_e(l.tags).length>0?_e(l.tags):void 0,transport:j},Se=e.markSubmitBusy(xe,"Creating...","plus","Create Channel");try{let Oe=z(S),Wt=await s.repository.getAvailablePath(s.repository.getSubfolder("channels"),Oe);await s.app.vault.create(Wt,U(ce,l.body.trim())),new Fe.Notice(`Channel "${Oe}" created.`),await s.refreshFromVault(),e.navigate("edit-channel",Oe)}catch(Oe){let Wt=Oe instanceof Error?Oe.message:String(Oe);new Fe.Notice(`Failed to create channel: ${Wt}`),Se()}}}var de=require("obsidian");var Ps=require("obsidian"),Qh=["bot","brain","shield-check","search","file-text","rocket","wand","sparkles","zap","target","compass","eye","code","terminal","database","globe","mail","message-circle","book","pen-tool","palette","music","camera","chart-bar","clipboard","cpu","server","cloud","lock","key","bell","calendar","clock","heart","star","flag","bookmark"],Ca=class extends Ps.Modal{constructor(t,s,n){super(t);this.onSelect=n;this.selectedIcon=s}searchQuery="";selectedIcon;allIcons=[];gridContainer;onOpen(){this.allIcons=(0,Ps.getIconIds)().sort();let{contentEl:t}=this;t.empty(),t.addClass("af-icon-picker-modal");let s=t.createEl("input",{cls:"af-icon-picker-search",attr:{type:"text",placeholder:"Search icons...",value:this.searchQuery}});this.gridContainer=t.createDiv({cls:"af-icon-picker-scroll"}),this.renderGrid(),s.addEventListener("input",()=>{this.searchQuery=s.value,this.renderGrid()}),window.setTimeout(()=>s.focus(),0)}renderGrid(){let t=this.gridContainer;t.empty();let s=this.searchQuery.toLowerCase().trim();if(!s)this.renderSection(t,"Popular",Qh),this.renderSection(t,"All Icons",this.allIcons);else{let n=this.allIcons.filter(i=>i.includes(s)),r=n.length===0?"No results":`${n.length} result${n.length!==1?"s":""}`;this.renderSection(t,r,n)}}renderSection(t,s,n){let r=t.createDiv({cls:"af-icon-picker-section"});r.createDiv({cls:"af-icon-picker-section-label",text:s});let i=r.createDiv({cls:"af-icon-picker-grid"});for(let o of n)this.renderIconItem(i,o)}renderIconItem(t,s){let n=t.createDiv({cls:`af-icon-picker-item${this.selectedIcon===s?" selected":""}`});n.setAttribute("title",s),n.setAttribute("aria-label",s),(0,Ps.setIcon)(n,s),n.addEventListener("click",()=>{this.selectedIcon=s,this.onSelect(s),this.close()})}onClose(){this.contentEl.empty()}};function Ta(a){let e={freq:"daily",hour:9,minute:0,days:[1],dayOfMonth:1};if(!a?.trim())return e;let t={"*/5 * * * *":"every_5m","*/15 * * * *":"every_15m","*/30 * * * *":"every_30m","0 * * * *":"every_hour","0 */2 * * *":"every_2h"};if(t[a])return{...e,freq:t[a]};let s=a.trim().split(/\s+/);if(s.length!==5)return e;let[n,r,i,,o]=s,l=parseInt(r??"9",10),c=parseInt(n??"0",10);if(i==="*"&&o==="*")return{...e,freq:"daily",hour:l,minute:c};if(i==="*"&&o==="1-5")return{...e,freq:"weekdays",hour:l,minute:c};if(i==="*"&&o!=="*"){let d=(o??"1").split(",").map(u=>parseInt(u,10));return{...e,freq:"weekly",hour:l,minute:c,days:d}}return o==="*"&&i!=="*"?{...e,freq:"monthly",hour:l,minute:c,dayOfMonth:parseInt(i??"1",10)}:{...e,hour:l,minute:c}}var Jl=[["claude-code","Claude Code",!1],["codex","Codex",!1],["process","Process (coming soon)",!0],["http","HTTP (coming soon)",!0]],Xl=[["bypassPermissions","Bypass Permissions","Auto-approve everything except deny list"],["dontAsk","Don\u2019t Ask","Auto-approve all tool calls"],["acceptEdits","Accept Edits","Auto-approve file edits, block bash unless allowed"],["plan","Plan","Read-only mode, no writes or commands"],["default","Default","Ask for each tool call"]],Ql=[["bypassPermissions","Bypass (no sandbox)","No sandbox, auto-approve everything"],["workspace-write","Workspace Write","Sandboxed: writes only inside the working dir"],["read-only","Read Only","Sandboxed: no writes or side-effect commands"]];function Es(a){let e=a.trim().toLowerCase();return e==="codex"||e==="openai-codex"}function _a(a){return Es(a)?Ql:Xl}function Aa(a,e){if(Es(e))switch(a){case"acceptEdits":case"default":return"workspace-write";case"plan":return"read-only";case"dontAsk":return"bypassPermissions";default:return Ql.some(([t])=>t===a)?a:"bypassPermissions"}switch(a){case"workspace-write":return"acceptEdits";case"read-only":return"plan";case"danger-full-access":return"bypassPermissions";default:return Xl.some(([t])=>t===a)?a:"bypassPermissions"}}function Zl(a){return Es(a)?"Codex enforces permissions via sandbox modes \u2014 your Claude mode was mapped: Accept Edits/Default \u2248 Workspace Write, Plan \u2248 Read Only, Don\u2019t Ask \u2248 Bypass.":"Claude Code uses permission modes \u2014 your Codex sandbox was mapped: Workspace Write \u2248 Accept Edits, Read Only \u2248 Plan."}function ec(a,e){let t=Ta(e.heartbeatSchedule),s=a.createDiv({cls:"af-form-row"});s.createDiv({cls:"af-form-label",text:"Frequency"});let n=s.createEl("select",{cls:"af-form-select"}),r=[["every_5m","Every 5 minutes"],["every_15m","Every 15 minutes"],["every_30m","Every 30 minutes"],["every_hour","Every hour"],["every_2h","Every 2 hours"],["every_4h","Every 4 hours"],["every_6h","Every 6 hours"],["every_12h","Every 12 hours"],["daily","Once a day"]],i="every_hour",o={"*/5 * * * *":"every_5m","*/15 * * * *":"every_15m","*/30 * * * *":"every_30m","0 * * * *":"every_hour","0 */2 * * *":"every_2h","0 */4 * * *":"every_4h","0 */6 * * *":"every_6h","0 */12 * * *":"every_12h"};o[e.heartbeatSchedule]?i=o[e.heartbeatSchedule]:(t.freq==="daily"||t.freq==="weekdays")&&(i="daily");for(let[m,f]of r){let g=n.createEl("option",{text:f,attr:{value:m}});m===i&&(g.selected=!0)}let l=a.createDiv({cls:"af-form-row af-schedule-time-row"});l.createDiv({cls:"af-form-label",text:"Time"});let c=l.createDiv({cls:"af-schedule-time-selects"}),d=c.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let m=0;m<24;m++){let f=m>=12?"PM":"AM",g=m===0?12:m>12?m-12:m,w=d.createEl("option",{text:`${g} ${f}`,attr:{value:String(m)}});m===t.hour&&(w.selected=!0)}c.createSpan({cls:"af-schedule-colon",text:":"});let u=c.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let m=0;m<60;m+=5){let f=u.createEl("option",{text:String(m).padStart(2,"0"),attr:{value:String(m)}});m===t.minute&&(f.selected=!0)}let h=()=>{l.setCssStyles({display:n.value==="daily"?"":"none"})},p=()=>{let m=n.value,f=d.value,g=u.value;switch(m){case"every_5m":e.heartbeatSchedule="*/5 * * * *";break;case"every_15m":e.heartbeatSchedule="*/15 * * * *";break;case"every_30m":e.heartbeatSchedule="*/30 * * * *";break;case"every_hour":e.heartbeatSchedule="0 * * * *";break;case"every_2h":e.heartbeatSchedule="0 */2 * * *";break;case"every_4h":e.heartbeatSchedule="0 */4 * * *";break;case"every_6h":e.heartbeatSchedule="0 */6 * * *";break;case"every_12h":e.heartbeatSchedule="0 */12 * * *";break;case"daily":e.heartbeatSchedule=`${g} ${f} * * *`;break}};n.addEventListener("change",()=>{h(),p()}),d.addEventListener("change",p),u.addEventListener("change",p),h()}function tc(a,e,t){a.createDiv({cls:"af-form-hint",text:"Servers from the MCP Servers tab. Checked servers are available to this agent on any adapter (Claude or Codex). Leave all unchecked to grant every enabled server."});let s=e.plugin.repository.getMcpServers();if(s.length===0){let r=a.createDiv({cls:"af-form-hint"});r.appendText("No MCP servers registered yet. ");let i=r.createEl("a",{cls:"af-link",text:"Add one in the MCP Servers tab."});i.onclick=o=>{o.preventDefault(),e.navigate("mcp")};return}let n=a.createDiv({cls:"af-create-skills-grid"});for(let r of s){let i=n.createDiv({cls:"af-mcp-agent-item"}),o=i.createEl("input",{cls:"af-form-toggle",attr:{type:"checkbox"}});o.checked=t.has(r.name),o.addEventListener("change",()=>{o.checked?t.add(r.name):t.delete(r.name)});let c=i.createDiv({cls:"af-mcp-agent-label"}).createDiv({cls:"af-mcp-agent-name-row"}),d=c.createSpan({cls:`af-mcp-status-dot ${r.enabled?"idle":"disabled"}`});d.title=r.enabled?"enabled":"disabled",c.createSpan({cls:"af-create-skill-name",text:r.name}),c.createSpan({cls:"af-mcp-agent-tool-count",text:r.type}),r.enabled||c.createSpan({cls:"af-mcp-agent-tool-count af-muted",text:"disabled"})}}function sc(a,e){let{plugin:t}=e,s=a.createDiv({cls:"af-detail-header"}),n=s.createDiv({cls:"af-detail-header-left"}),r=n.createDiv({cls:"af-agent-card-avatar idle"});(0,de.setIcon)(r,"plus");let i=n.createDiv();i.createDiv({cls:"af-detail-header-name",text:"Create New Agent"}),i.createDiv({cls:"af-detail-header-desc",text:"Configure a new agent for your fleet"}),s.createDiv({cls:"af-detail-header-actions"});let o={name:"",description:"",avatar:"",tags:"",systemPrompt:"",model:"default",adapter:"claude-code",cwd:"",timeout:300,permissionMode:"bypassPermissions",effort:"",selectedSkills:new Set,selectedMcpServers:new Set,skillsBody:"",contextBody:"",approvalRequired:"",memory:!0,enabled:!0,allowedCommands:"",blockedCommands:"",heartbeatEnabled:!1,heartbeatSchedule:"0 */6 * * *",heartbeatBody:"",heartbeatNotify:!0,heartbeatChannel:"",heartbeatChannelTarget:"",autoCompactThreshold:85,wikiReferences:[]},l={none:{label:"None",prompt:""},coding:{label:"Coding Agent",prompt:`You are a coding agent. Review code, write tests, fix bugs, and implement features. Follow existing code conventions. Write clean, well-tested code. If something is unclear, ask for clarification instead of guessing.`},monitor:{label:"Monitor",prompt:`You are a monitoring agent. Check system status, alert on failures, and report on health metrics. Be concise and factual. Highlight anomalies clearly. @@ -11967,13 +11949,23 @@ Include timestamps and relevant context in all reports.`},briefing:{label:"Brief Prioritize recent and important changes. Keep summaries concise. End with explicit next actions if they exist.`},reviewer:{label:"Code Reviewer",prompt:`You are a code review agent. Analyze pull requests, suggest improvements, and flag potential issues. Focus on correctness, security, and maintainability. -Be specific \u2014 reference file names and line numbers.`}},d=s.createDiv({cls:"af-create-form"}),u=d.createDiv({cls:"af-create-section"}),p=u.createDiv({cls:"af-create-section-header"}),m=p.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(m,"user"),p.createSpan({text:"Identity"}),this.createFormField(u,"Name","deploy-watcher","Unique identifier (will be slugified)",B=>{l.name=B}),this.createFormField(u,"Description","Monitors deployments and reports status","",B=>{l.description=B});let f=u.createDiv({cls:"af-form-row"});f.createDiv({cls:"af-form-label",text:"Avatar"});let g=f.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"text",placeholder:"\u{1F6E1}\uFE0F"}});g.addEventListener("input",()=>{l.avatar=g.value}),this.createFormField(u,"Tags","devops, monitoring","Comma-separated",B=>{l.tags=B});let y=u.createDiv({cls:"af-form-row af-form-row-toggle"});y.createDiv({cls:"af-form-label",text:"Enabled"});let v=y.createDiv({cls:"af-agent-card-toggle on"});v.onclick=()=>{let B=v.hasClass("on");v.toggleClass("on",!B),l.enabled=!B};let k=d.createDiv({cls:"af-create-section"}),w=k.createDiv({cls:"af-create-section-header"}),S=w.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(S,"message-square"),w.createSpan({text:"System Prompt"});let T=k.createDiv({cls:"af-form-row"});T.createDiv({cls:"af-form-label",text:"Template"});let _=T.createEl("select",{cls:"af-form-select"});for(let[B,{label:V}]of Object.entries(h))_.createEl("option",{text:V,attr:{value:B}});let D=k.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"You are a deployment monitoring agent...",rows:"10"}});D.addEventListener("input",()=>{l.systemPrompt=D.value}),_.addEventListener("change",()=>{let B=h[_.value];B&&_.value!=="none"&&(l.systemPrompt=B.prompt,D.value=B.prompt)});let O=d.createDiv({cls:"af-create-section"}),C=O.createDiv({cls:"af-create-section-header"}),E=C.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(E,"settings"),C.createSpan({text:"Runtime Config"});let P=O.createDiv({cls:"af-create-config-grid"}),N=P.createDiv({cls:"af-form-row"});N.createDiv({cls:"af-form-label",text:"Adapter"});let M=N.createEl("select",{cls:"af-form-select"});for(let[B,V,Ee]of Xo){let we=M.createEl("option",{text:V,attr:{value:B,...Ee?{disabled:"true"}:{}}});B==="claude-code"&&(we.selected=!0)}let U=P.createDiv({cls:"af-form-row"}),$=U.createDiv({cls:"af-form-label",text:"Model"});this.addTooltip($,`Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${this.plugin.settings.defaultModel||"CLI default"}).`);let Z=U.createDiv({cls:"af-form-field-wrap"}),de=()=>{Rt(Z,{value:l.model,adapter:l.adapter,onChange:B=>{l.model=B}})};de();let me=()=>{};M.addEventListener("change",()=>{l.adapter=M.value,(Ls(l.adapter)?Qs:Zs).some(V=>V.value===l.model.trim())&&(l.model=""),de(),l.permissionMode=Hn(l.permissionMode,l.adapter),me()});let ge=P.createDiv({cls:"af-form-row"});ge.createDiv({cls:"af-form-label",text:"Working Dir"});let X=ge.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Leave empty for vault root"}});X.addEventListener("input",()=>{l.cwd=X.value});let W=P.createDiv({cls:"af-form-row"});W.createDiv({cls:"af-form-label",text:"Timeout (sec)"});let K=W.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",value:"300"}});K.addEventListener("input",()=>{let B=parseInt(K.value,10);!isNaN(B)&&B>0&&(l.timeout=B)});let ne=P.createDiv({cls:"af-form-row"});ne.createDiv({cls:"af-form-label",text:"Permission Mode"});let G=ne.createEl("select",{cls:"af-form-select"}),ee=P.createDiv({cls:"af-form-hint",text:""});me=()=>{l.permissionMode=Hn(l.permissionMode,l.adapter);let B=Wn(l.adapter);G.empty();for(let[V,Ee]of B){let we=G.createEl("option",{text:Ee,attr:{value:V}});V===l.permissionMode&&(we.selected=!0)}ee.textContent=B.find(([V])=>V===G.value)?.[2]??""},me(),G.addEventListener("change",()=>{l.permissionMode=G.value,ee.textContent=Wn(l.adapter).find(([B])=>B===G.value)?.[2]??""});let Q=P.createDiv({cls:"af-form-row"});Q.createDiv({cls:"af-form-label",text:"Effort Level"});let ae=Q.createEl("select",{cls:"af-form-select"});for(let[B,V]of[["","Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]])ae.createEl("option",{text:V,attr:{value:B}});ae.addEventListener("change",()=>{l.effort=ae.value}),P.createDiv({cls:"af-form-hint",text:"Controls reasoning depth \u2014 low is fast, max is most thorough"});let ve=P.createDiv({cls:"af-form-row"}),Pe=ve.createDiv({cls:"af-form-label",text:"Auto-compact at"});this.addTooltip(Pe,"Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.");let Ie=ve.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",min:"0",max:"100",value:String(l.autoCompactThreshold)}});Ie.addEventListener("input",()=>{let B=parseInt(Ie.value,10);!isNaN(B)&&B>=0&&B<=100&&(l.autoCompactThreshold=B)}),P.createDiv({cls:"af-form-hint",text:"0 disables auto-compact"});{let B=this.plugin.runtime.getSnapshot().agents.filter(V=>V.wikiKeeper!==void 0);if(B.length>0){let V=P.createDiv({cls:"af-form-row af-form-row-toggle"}),Ee=V.createDiv({cls:"af-form-label",text:"Wiki access"});this.addTooltip(Ee,"Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).");let we=V.createDiv({cls:"af-form-field-wrap"});for(let Ae of B){let nt=we.createEl("label",{cls:"af-form-checkbox-row"}),je=nt.createEl("input",{attr:{type:"checkbox"}});nt.createSpan({text:` ${Ae.name}`,cls:"af-form-checkbox-label"}),je.addEventListener("change",()=>{je.checked?l.wikiReferences.includes(Ae.name)||l.wikiReferences.push(Ae.name):l.wikiReferences=l.wikiReferences.filter(Tt=>Tt!==Ae.name)})}}}{let B=d.createDiv({cls:"af-create-section"}),V=B.createDiv({cls:"af-create-section-header"}),Ee=V.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Ee,"heart-pulse");let we=V.createSpan({text:"Heartbeat"});this.addTooltip(we,"Autonomous periodic run \u2014 what the agent does when no one is asking");let Ae=B.createDiv({cls:"af-form-row af-form-row-toggle"});Ae.createDiv({cls:"af-form-label",text:"Enabled"});let nt=Ae.createDiv({cls:"af-agent-card-toggle"}),je=B.createDiv();je.setCssStyles({display:"none"}),nt.onclick=()=>{let ze=nt.hasClass("on");nt.toggleClass("on",!ze),l.heartbeatEnabled=!ze,je.setCssStyles({display:ze?"none":""})},this.renderHeartbeatSchedule(je,l);let Tt=je.createDiv({cls:"af-form-row af-form-row-toggle"}),Ut=Tt.createDiv({cls:"af-form-label"});Ut.setText("Notify"),this.addTooltip(Ut,"Show an Obsidian notice when the heartbeat completes");let bt=Tt.createDiv({cls:"af-agent-card-toggle on"});bt.onclick=()=>{let ze=bt.hasClass("on");bt.toggleClass("on",!ze),l.heartbeatNotify=!ze};let hs=this.plugin.runtime.getSnapshot(),Ns=je.createDiv({cls:"af-form-row"}),$t=Ns.createDiv({cls:"af-form-label"});$t.setText("Post to channel"),this.addTooltip($t,"Heartbeat results are posted to this Slack channel when the run completes");let L=Ns.createEl("select",{cls:"af-form-select"});L.createEl("option",{text:"(none)",attr:{value:""}});for(let ze of hs.channels)L.createEl("option",{text:ze.name,attr:{value:ze.name}});L.addEventListener("change",()=>{l.heartbeatChannel=L.value,qe()});let Y=je.createDiv({cls:"af-form-row"}),Te=Y.createDiv({cls:"af-form-label"});Te.setText("Target ID"),this.addTooltip(Te,"Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel\u2019s first allowed user.");let Le=Y.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Channel/chat id \u2014 empty = DM"}});Le.value=l.heartbeatChannelTarget,Le.addEventListener("input",()=>{l.heartbeatChannelTarget=Le.value.trim()});let qe=()=>{Y.setCssStyles({display:l.heartbeatChannel?"":"none"})};qe();let at=je.createDiv({cls:"af-form-label"});at.setCssStyles({width:"auto"}),at.setCssStyles({marginTop:"12px"}),at.setText("Instruction"),this.addTooltip(at,'What the agent does on each heartbeat. Also used by the "Run Now" button.');let We=je.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Check status, scan for issues, report findings...",rows:"8"}});We.addEventListener("input",()=>{l.heartbeatBody=We.value})}let Me=d.createDiv({cls:"af-create-section"}),Fe=Me.createDiv({cls:"af-create-section-header"}),ke=Fe.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(ke,"puzzle"),Fe.createSpan({text:"Skills"});let Oe=this.plugin.runtime.getSnapshot();if(Oe.skills.length>0){Me.createDiv({cls:"af-form-sublabel",text:"Shared Skills"});let B=Me.createDiv({cls:"af-create-skills-grid"});for(let V of Oe.skills){let Ee=B.createDiv({cls:"af-create-skill-item"}),we=Ee.createEl("input",{cls:"af-form-toggle",attr:{type:"checkbox"}});we.addEventListener("change",()=>{we.checked?l.selectedSkills.add(V.name):l.selectedSkills.delete(V.name)});let Ae=Ee.createDiv({cls:"af-create-skill-label"});Ae.createSpan({cls:"af-create-skill-name",text:V.name}),V.description&&Ae.createSpan({cls:"af-create-skill-desc",text:` \u2014 ${V.description}`})}}let Ue=Me.createDiv({cls:"af-form-sublabel"});Ue.setText("Agent-specific skills"),this.addTooltip(Ue,"Custom skills/instructions only for this agent, not shared with others");let Ne=Me.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Custom skills/instructions for this agent...",rows:"4"}});Ne.addEventListener("input",()=>{l.skillsBody=Ne.value});{let B=d.createDiv({cls:"af-create-section"}),V=B.createDiv({cls:"af-create-section-header"}),Ee=V.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Ee,"plug");let we=V.createSpan({text:"MCP Servers"});this.addTooltip(we,"Grant agent access to MCP servers"),this.renderAgentMcpPicker(B,l.selectedMcpServers)}let q=d.createDiv({cls:"af-create-section"}),re=q.createDiv({cls:"af-create-section-header"}),j=re.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(j,"file-text");let Ce=re.createSpan({text:"Context"});this.addTooltip(Ce,"Project-specific context included in every run");let xe=q.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Background info, repo structure, conventions...",rows:"4"}});xe.addEventListener("input",()=>{l.contextBody=xe.value});let he=d.createDiv({cls:"af-create-section"}),$e=he.createDiv({cls:"af-create-section-header"}),ie=$e.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(ie,"shield-check"),$e.createSpan({text:"Permissions"}),this.createFormField(he,"Approval required","git_push, file_delete","Comma-separated tool names",B=>{l.approvalRequired=B});let I=he.createDiv({cls:"af-form-row"});I.createDiv({cls:"af-form-label",text:"Allowed Commands"});let se=I.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(curl *) +Be specific \u2014 reference file names and line numbers.`}},c=a.createDiv({cls:"af-create-form"}),d=c.createDiv({cls:"af-create-section"}),u=d.createDiv({cls:"af-create-section-header"}),h=u.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(h,"user"),u.createSpan({text:"Identity"}),e.createFormField(d,"Name","deploy-watcher","Unique identifier (will be slugified)",L=>{o.name=L}),e.createFormField(d,"Description","Monitors deployments and reports status","",L=>{o.description=L});let p=d.createDiv({cls:"af-form-row"});p.createDiv({cls:"af-form-label",text:"Avatar"});let m=p.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"text",placeholder:"\u{1F6E1}\uFE0F"}});m.addEventListener("input",()=>{o.avatar=m.value}),e.createFormField(d,"Tags","devops, monitoring","Comma-separated",L=>{o.tags=L});let f=d.createDiv({cls:"af-form-row af-form-row-toggle"});f.createDiv({cls:"af-form-label",text:"Enabled"});let g=f.createDiv({cls:"af-agent-card-toggle on"});g.onclick=()=>{let L=g.hasClass("on");g.toggleClass("on",!L),o.enabled=!L};let w=c.createDiv({cls:"af-create-section"}),y=w.createDiv({cls:"af-create-section-header"}),v=y.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(v,"message-square"),y.createSpan({text:"System Prompt"});let k=w.createDiv({cls:"af-form-row"});k.createDiv({cls:"af-form-label",text:"Template"});let b=k.createEl("select",{cls:"af-form-select"});for(let[L,{label:V}]of Object.entries(l))b.createEl("option",{text:V,attr:{value:L}});let P=w.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"You are a deployment monitoring agent...",rows:"10"}});P.addEventListener("input",()=>{o.systemPrompt=P.value}),b.addEventListener("change",()=>{let L=l[b.value];L&&b.value!=="none"&&(o.systemPrompt=L.prompt,P.value=L.prompt)});let A=c.createDiv({cls:"af-create-section"}),M=A.createDiv({cls:"af-create-section-header"}),E=M.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(E,"settings"),M.createSpan({text:"Runtime Config"});let x=A.createDiv({cls:"af-create-config-grid"}),_=x.createDiv({cls:"af-form-row"});_.createDiv({cls:"af-form-label",text:"Adapter"});let R=_.createEl("select",{cls:"af-form-select"});for(let[L,V,De]of Jl){let Re=R.createEl("option",{text:V,attr:{value:L,...De?{disabled:"true"}:{}}});L==="claude-code"&&(Re.selected=!0)}let O=x.createDiv({cls:"af-form-row"}),F=O.createDiv({cls:"af-form-label",text:"Model"});e.addTooltip(F,`Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${t.settings.defaultModel||"CLI default"}).`);let B=O.createDiv({cls:"af-form-field-wrap"}),q=()=>{Nt(B,{value:o.model,adapter:o.adapter,onChange:L=>{o.model=L}})};q();let oe=()=>{},be=()=>{};R.addEventListener("change",()=>{o.adapter=R.value,(Es(o.adapter)?Nn:Bn).some(V=>V.value===o.model.trim())&&(o.model=""),q(),o.permissionMode=Aa(o.permissionMode,o.adapter),oe(),be()});let ne=x.createDiv({cls:"af-form-row"});ne.createDiv({cls:"af-form-label",text:"Working Dir"});let Y=ne.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Leave empty for vault root"}});Y.addEventListener("input",()=>{o.cwd=Y.value});let H=x.createDiv({cls:"af-form-row"});H.createDiv({cls:"af-form-label",text:"Timeout (sec)"});let X=H.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",value:"300"}});X.addEventListener("input",()=>{let L=parseInt(X.value,10);!isNaN(L)&&L>0&&(o.timeout=L)});let me=x.createDiv({cls:"af-form-row"});me.createDiv({cls:"af-form-label",text:"Permission Mode"});let ee=me.createEl("select",{cls:"af-form-select"}),J=x.createDiv({cls:"af-form-hint",text:""});oe=()=>{o.permissionMode=Aa(o.permissionMode,o.adapter);let L=_a(o.adapter);ee.empty();for(let[V,De]of L){let Re=ee.createEl("option",{text:De,attr:{value:V}});V===o.permissionMode&&(Re.selected=!0)}J.textContent=L.find(([V])=>V===ee.value)?.[2]??""},oe();let K=x.createDiv({cls:"af-form-hint af-adapter-map-hint"});K.setCssStyles({display:"none"}),be=()=>{K.setText(Zl(o.adapter)),K.setCssStyles({display:""})},ee.addEventListener("change",()=>{o.permissionMode=ee.value,J.textContent=_a(o.adapter).find(([L])=>L===ee.value)?.[2]??""});let ke=x.createDiv({cls:"af-form-row"});ke.createDiv({cls:"af-form-label",text:"Effort Level"});let je=ke.createEl("select",{cls:"af-form-select"});for(let[L,V]of[["","Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]])je.createEl("option",{text:V,attr:{value:L}});je.addEventListener("change",()=>{o.effort=je.value}),x.createDiv({cls:"af-form-hint",text:"Controls reasoning depth \u2014 low is fast, max is most thorough"});let Qe=x.createDiv({cls:"af-form-row"}),Ze=Qe.createDiv({cls:"af-form-label",text:"Auto-compact at"});e.addTooltip(Ze,"Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.");let He=Qe.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",min:"0",max:"100",value:String(o.autoCompactThreshold)}});He.addEventListener("input",()=>{let L=parseInt(He.value,10);!isNaN(L)&&L>=0&&L<=100&&(o.autoCompactThreshold=L)}),x.createDiv({cls:"af-form-hint",text:"0 disables auto-compact"});{let L=t.runtime.getSnapshot().agents.filter(V=>V.wikiKeeper!==void 0);if(L.length>0){let V=x.createDiv({cls:"af-form-row af-form-row-toggle"}),De=V.createDiv({cls:"af-form-label",text:"Wiki access"});e.addTooltip(De,"Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).");let Re=V.createDiv({cls:"af-form-field-wrap"});for(let ye of L){let Ue=Re.createEl("label",{cls:"af-form-checkbox-row"}),$e=Ue.createEl("input",{attr:{type:"checkbox"}});Ue.createSpan({text:` ${ye.name}`,cls:"af-form-checkbox-label"}),$e.addEventListener("change",()=>{$e.checked?o.wikiReferences.includes(ye.name)||o.wikiReferences.push(ye.name):o.wikiReferences=o.wikiReferences.filter(Ot=>Ot!==ye.name)})}}}{let L=c.createDiv({cls:"af-create-section"}),V=L.createDiv({cls:"af-create-section-header"}),De=V.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(De,"heart-pulse");let Re=V.createSpan({text:"Heartbeat"});e.addTooltip(Re,"Autonomous periodic run \u2014 what the agent does when no one is asking");let ye=L.createDiv({cls:"af-form-row af-form-row-toggle"});ye.createDiv({cls:"af-form-label",text:"Enabled"});let Ue=ye.createDiv({cls:"af-agent-card-toggle"}),$e=L.createDiv();$e.setCssStyles({display:"none"}),Ue.onclick=()=>{let ct=Ue.hasClass("on");Ue.toggleClass("on",!ct),o.heartbeatEnabled=!ct,$e.setCssStyles({display:ct?"none":""})},ec($e,o);let Ot=$e.createDiv({cls:"af-form-row af-form-row-toggle"}),qt=Ot.createDiv({cls:"af-form-label"});qt.setText("Notify"),e.addTooltip(qt,"Show an Obsidian notice when the heartbeat completes");let zt=Ot.createDiv({cls:"af-agent-card-toggle on"});zt.onclick=()=>{let ct=zt.hasClass("on");zt.toggleClass("on",!ct),o.heartbeatNotify=!ct};let Ia=t.runtime.getSnapshot(),Gt=$e.createDiv({cls:"af-form-row"}),I=Gt.createDiv({cls:"af-form-label"});I.setText("Post to channel"),e.addTooltip(I,"Heartbeat results are posted to this Slack channel when the run completes");let te=Gt.createEl("select",{cls:"af-form-select"});te.createEl("option",{text:"(none)",attr:{value:""}});for(let ct of Ia.channels)te.createEl("option",{text:ct.name,attr:{value:ct.name}});te.addEventListener("change",()=>{o.heartbeatChannel=te.value,Ct()});let ue=$e.createDiv({cls:"af-form-row"}),Ae=ue.createDiv({cls:"af-form-label"});Ae.setText("Target ID"),e.addTooltip(Ae,"Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel\u2019s first allowed user.");let Ne=ue.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Channel/chat id \u2014 empty = DM"}});Ne.value=o.heartbeatChannelTarget,Ne.addEventListener("input",()=>{o.heartbeatChannelTarget=Ne.value.trim()});let Ct=()=>{ue.setCssStyles({display:o.heartbeatChannel?"":"none"})};Ct();let Be=$e.createDiv({cls:"af-form-label"});Be.setCssStyles({width:"auto"}),Be.setCssStyles({marginTop:"12px"}),Be.setText("Instruction"),e.addTooltip(Be,'What the agent does on each heartbeat. Also used by the "Run Now" button.');let Vt=$e.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Check status, scan for issues, report findings...",rows:"8"}});Vt.addEventListener("input",()=>{o.heartbeatBody=Vt.value})}let $=c.createDiv({cls:"af-create-section"}),ae=$.createDiv({cls:"af-create-section-header"}),ge=ae.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(ge,"puzzle"),ae.createSpan({text:"Skills"});let et=t.runtime.getSnapshot();if(et.skills.length>0){$.createDiv({cls:"af-form-sublabel",text:"Shared Skills"});let L=$.createDiv({cls:"af-create-skills-grid"});for(let V of et.skills){let De=L.createDiv({cls:"af-create-skill-item"}),Re=De.createEl("input",{cls:"af-form-toggle",attr:{type:"checkbox"}});Re.addEventListener("change",()=>{Re.checked?o.selectedSkills.add(V.name):o.selectedSkills.delete(V.name)});let ye=De.createDiv({cls:"af-create-skill-label"});ye.createSpan({cls:"af-create-skill-name",text:V.name}),V.description&&ye.createSpan({cls:"af-create-skill-desc",text:` \u2014 ${V.description}`})}}let xe=$.createDiv({cls:"af-form-sublabel"});xe.setText("Agent-specific skills"),e.addTooltip(xe,"Custom skills/instructions only for this agent, not shared with others");let Ke=$.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Custom skills/instructions for this agent...",rows:"4"}});Ke.addEventListener("input",()=>{o.skillsBody=Ke.value});{let L=c.createDiv({cls:"af-create-section"}),V=L.createDiv({cls:"af-create-section-header"}),De=V.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(De,"plug");let Re=V.createSpan({text:"MCP Servers"});e.addTooltip(Re,"Grant agent access to MCP servers"),tc(L,e,o.selectedMcpServers)}let _e=c.createDiv({cls:"af-create-section"}),Le=_e.createDiv({cls:"af-create-section-header"}),S=Le.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(S,"file-text");let j=Le.createSpan({text:"Context"});e.addTooltip(j,"Project-specific context included in every run");let ce=_e.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Background info, repo structure, conventions...",rows:"4"}});ce.addEventListener("input",()=>{o.contextBody=ce.value});let Se=c.createDiv({cls:"af-create-section"}),Oe=Se.createDiv({cls:"af-create-section-header"}),Wt=Oe.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(Wt,"shield-check"),Oe.createSpan({text:"Permissions"}),e.createFormField(Se,"Approval required","git_push, file_delete","Comma-separated tool names",L=>{o.approvalRequired=L});let mn=Se.createDiv({cls:"af-form-row"});mn.createDiv({cls:"af-form-label",text:"Allowed Commands"});let ls=mn.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(curl *) Bash(python3 *) Read Edit -Write`,rows:"4"}});se.addEventListener("input",()=>{l.allowedCommands=se.value});let ce=he.createDiv({cls:"af-form-row"});ce.createDiv({cls:"af-form-label",text:"Blocked Commands"});let te=ce.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(git push *) +Write`,rows:"4"}});ls.addEventListener("input",()=>{o.allowedCommands=ls.value});let lt=Se.createDiv({cls:"af-form-row"});lt.createDiv({cls:"af-form-label",text:"Blocked Commands"});let Fs=lt.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(git push *) Bash(rm -rf *) -Bash(sudo *)`,rows:"4"}});te.addEventListener("input",()=>{l.blockedCommands=te.value}),he.createDiv({cls:"af-form-hint",text:"On Codex agents these become execpolicy command rules \u2014 only Bash(cmd args *) prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are ignored, and file/network access is governed by Permission Mode (the sandbox)."});let st=he.createDiv({cls:"af-form-row"});st.createDiv({cls:"af-form-label",text:"Memory enabled"});let ls=st.createDiv({cls:"af-agent-card-toggle on"});ls.onclick=()=>{let B=ls.hasClass("on");ls.toggleClass("on",!B),l.memory=!B};let cs=s.createDiv({cls:"af-create-footer"}),ds=cs.createEl("button",{cls:"af-btn-sm",text:"Cancel"});ds.onclick=()=>this.navigate("agents");let Bt=cs.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(Bt,"plus","af-btn-icon"),Bt.appendText(" Create Agent"),Bt.onclick=async()=>{let B=l.name.trim();if(!B){new b.Notice("Agent name is required.");return}let V=oe(B);if(this.plugin.repository.getAgentByName(V)){new b.Notice(`Agent "${V}" already exists.`);return}let Ee=we=>we.split(",").map(Ae=>Ae.trim()).filter(Boolean);try{let we=Ae=>ye(Ae).map(nt=>nt.trim()).filter(Boolean);await this.plugin.repository.createAgentFolder({name:V,description:l.description.trim(),avatar:l.avatar.trim(),tags:Ee(l.tags),systemPrompt:l.systemPrompt.trim(),model:l.model.trim()||"default",adapter:l.adapter,cwd:l.cwd.trim(),timeout:l.timeout,permissionMode:l.permissionMode,effort:l.effort||void 0,approvalRequired:Ee(l.approvalRequired),memory:l.memory,memoryMaxEntries:100,skills:Array.from(l.selectedSkills),mcpServers:Array.from(l.selectedMcpServers),skillsBody:l.skillsBody.trim(),contextBody:l.contextBody.trim(),enabled:l.enabled,permissionRules:{allow:we(l.allowedCommands),deny:we(l.blockedCommands)},autoCompactThreshold:l.autoCompactThreshold,wikiReferences:l.wikiReferences}),l.heartbeatEnabled&&l.heartbeatBody.trim()&&await this.plugin.repository.updateHeartbeat(V,{enabled:l.heartbeatEnabled,schedule:l.heartbeatSchedule.trim(),notify:l.heartbeatNotify,channel:l.heartbeatChannel,channelTarget:l.heartbeatChannel?l.heartbeatChannelTarget:"",body:l.heartbeatBody.trim()}),new b.Notice(`Agent "${V}" created.`),await this.plugin.refreshFromVault(),this.navigate("agent-detail",V)}catch(we){let Ae=we instanceof Error?we.message:String(we);new b.Notice(`Failed to create agent: ${Ae}`)}}}renderCreateSkillPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=s.createDiv({cls:"af-detail-header"}),a=n.createDiv({cls:"af-detail-header-left"}),i=a.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(i,"plus");let o=a.createDiv();o.createDiv({cls:"af-detail-header-name",text:"Create New Skill"}),o.createDiv({cls:"af-detail-header-desc",text:"Define a reusable skill for your agents"});let c=n.createDiv({cls:"af-detail-header-actions"}),l={name:"",description:"",tags:"",body:"",toolsBody:"",referencesBody:"",examplesBody:""},h={none:{label:"None",prompt:""},cli:{label:"CLI Tool Wrapper",prompt:`You are using the {{tool}} CLI. All operations go through the wrapper script. +Bash(sudo *)`,rows:"4"}});Fs.addEventListener("input",()=>{o.blockedCommands=Fs.value}),Se.createDiv({cls:"af-form-hint",text:"On Codex agents these become execpolicy command rules \u2014 only Bash(cmd args *) prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are ignored, and file/network access is governed by Permission Mode (the sandbox)."});let fn=Se.createDiv({cls:"af-form-row"});fn.createDiv({cls:"af-form-label",text:"Memory enabled"});let cs=fn.createDiv({cls:"af-agent-card-toggle on"});cs.onclick=()=>{let L=cs.hasClass("on");cs.toggleClass("on",!L),o.memory=!L};let ds=a.createDiv({cls:"af-create-footer"}),gn=ds.createEl("button",{cls:"af-btn-sm",text:"Cancel"});gn.onclick=()=>e.navigate("agents");let Lt=ds.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(Lt,"plus","af-btn-icon"),Lt.appendText(" Create Agent"),Lt.onclick=async()=>{let L=o.name.trim();if(!L){new de.Notice("Agent name is required.");return}let V=z(L);if(t.repository.getAgentByName(V)){new de.Notice(`Agent "${V}" already exists.`);return}let De=ye=>ye.split(",").map(Ue=>Ue.trim()).filter(Boolean),Re=e.markSubmitBusy(Lt,"Creating...","plus","Create Agent");try{let ye=Ue=>re(Ue).map($e=>$e.trim()).filter(Boolean);await t.repository.createAgentFolder({name:V,description:o.description.trim(),avatar:o.avatar.trim(),tags:De(o.tags),systemPrompt:o.systemPrompt.trim(),model:o.model.trim()||"default",adapter:o.adapter,cwd:o.cwd.trim(),timeout:o.timeout,permissionMode:o.permissionMode,effort:o.effort||void 0,approvalRequired:De(o.approvalRequired),memory:o.memory,memoryMaxEntries:100,skills:Array.from(o.selectedSkills),mcpServers:Array.from(o.selectedMcpServers),skillsBody:o.skillsBody.trim(),contextBody:o.contextBody.trim(),enabled:o.enabled,permissionRules:{allow:ye(o.allowedCommands),deny:ye(o.blockedCommands)},autoCompactThreshold:o.autoCompactThreshold,wikiReferences:o.wikiReferences}),o.heartbeatEnabled&&o.heartbeatBody.trim()&&await t.repository.updateHeartbeat(V,{enabled:o.heartbeatEnabled,schedule:o.heartbeatSchedule.trim(),notify:o.heartbeatNotify,channel:o.heartbeatChannel,channelTarget:o.heartbeatChannel?o.heartbeatChannelTarget:"",body:o.heartbeatBody.trim()}),new de.Notice(`Agent "${V}" created.`),await t.refreshFromVault(),e.navigate("agent-detail",V)}catch(ye){let Ue=ye instanceof Error?ye.message:String(ye);new de.Notice(`Failed to create agent: ${Ue}`),Re()}}}function nc(a,e,t){let{plugin:s}=e,n=a.createDiv({cls:"af-detail-header"}),r=n.createDiv({cls:"af-detail-header-left"}),i=r.createDiv({cls:"af-agent-card-avatar idle"});(0,de.setIcon)(i,"edit");let o=r.createDiv();o.createDiv({cls:"af-detail-header-name",text:`Edit Agent: ${t.name}`}),o.createDiv({cls:"af-detail-header-desc",text:"Modify agent configuration"}),n.createDiv({cls:"af-detail-header-actions"});let l={name:t.name,description:t.description??"",avatar:t.avatar,tags:t.tags.join(", "),systemPrompt:t.body,model:t.model,adapter:t.adapter,cwd:t.cwd??"",timeout:t.timeout,permissionMode:t.permissionMode,effort:t.effort??"",selectedSkills:new Set(t.skills),selectedMcpServers:new Set(t.mcpServers??[]),skillsBody:t.skillsBody,contextBody:t.contextBody,approvalRequired:t.approvalRequired.join(", "),memory:t.memory,memoryTokenBudget:t.memoryTokenBudget,reflectionEnabled:t.reflection.enabled,reflectionProposeSkills:t.reflection.proposeSkills,enabled:t.enabled,allowedCommands:t.permissionRules.allow.join(` +`),blockedCommands:t.permissionRules.deny.join(` +`),heartbeatEnabled:t.heartbeatEnabled,heartbeatSchedule:t.heartbeatSchedule,heartbeatBody:t.heartbeatBody,heartbeatNotify:t.heartbeatNotify,heartbeatChannel:t.heartbeatChannel,heartbeatChannelTarget:t.heartbeatChannelTarget,autoCompactThreshold:t.autoCompactThreshold??85,wikiReferences:(t.wikiReferences??[]).map(I=>I.agent)},c=a.createDiv({cls:"af-create-form"}),d=c.createDiv({cls:"af-create-section"}),u=d.createDiv({cls:"af-create-section-header"}),h=u.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(h,"user"),u.createSpan({text:"Identity"});let p=d.createDiv({cls:"af-form-row"});p.createDiv({cls:"af-form-label",text:"Name"}),p.createEl("input",{cls:"af-form-input",attr:{type:"text",value:t.name,disabled:"true"}}).setCssStyles({opacity:"0.6"}),e.createFormField(d,"Description","Monitors deployments and reports status","",I=>{l.description=I},t.description??"");let f=d.createDiv({cls:"af-form-row"});f.createDiv({cls:"af-form-label",text:"Avatar"});let g=f.createEl("button",{cls:"af-avatar-picker-btn"}),w=g.createDiv({cls:"af-avatar-picker-preview"});e.renderAgentAvatar(w,{...t,avatar:l.avatar??t.avatar}),g.createSpan({cls:"af-avatar-picker-label",text:l.avatar||t.avatar||"Pick icon\u2026"}),g.addEventListener("click",()=>{new Ca(s.app,l.avatar??t.avatar,I=>{l.avatar=I,w.empty(),(0,de.setIcon)(w,I),g.querySelector(".af-avatar-picker-label")?.setText(I)}).open()}),e.createFormField(d,"Tags","devops, monitoring","Comma-separated",I=>{l.tags=I},t.tags.join(", "));let y=d.createDiv({cls:"af-form-row"});y.createDiv({cls:"af-form-label",text:"Enabled"});let v=y.createDiv({cls:`af-agent-card-toggle${t.enabled?" on":""}`});v.onclick=()=>{let I=v.hasClass("on");v.toggleClass("on",!I),l.enabled=!I};let k=c.createDiv({cls:"af-create-section"}),b=k.createDiv({cls:"af-create-section-header"}),P=b.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(P,"message-square"),b.createSpan({text:"System Prompt"});let A=k.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"System prompt...",rows:"10"}});A.value=t.body,A.addEventListener("input",()=>{l.systemPrompt=A.value});let M=c.createDiv({cls:"af-create-section"}),E=M.createDiv({cls:"af-create-section-header"}),x=E.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(x,"settings"),E.createSpan({text:"Runtime Config"});let _=M.createDiv({cls:"af-create-config-grid"}),R=_.createDiv({cls:"af-form-row"});R.createDiv({cls:"af-form-label",text:"Adapter"});let O=R.createEl("select",{cls:"af-form-select"});for(let[I,te,ue]of Jl){let Ae=O.createEl("option",{text:te,attr:{value:I,...ue?{disabled:"true"}:{}}});(I===t.adapter||Es(t.adapter)&&I==="codex")&&(Ae.selected=!0)}let F=_.createDiv({cls:"af-form-row"}),B=F.createDiv({cls:"af-form-label",text:"Model"});e.addTooltip(B,`Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${s.settings.defaultModel||"CLI default"}).`);let q=F.createDiv({cls:"af-form-field-wrap"}),oe=()=>{Nt(q,{value:l.model,adapter:l.adapter,onChange:I=>{l.model=I}})};oe();let be=()=>{},ne=()=>{};O.addEventListener("change",()=>{l.adapter=O.value,(Es(l.adapter)?Nn:Bn).some(te=>te.value===l.model.trim())&&(l.model=""),oe(),l.permissionMode=Aa(l.permissionMode,l.adapter),be(),ne()});let Y=_.createDiv({cls:"af-form-row"});Y.createDiv({cls:"af-form-label",text:"Working Dir"});let H=Y.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Leave empty for vault root",value:t.cwd??""}});H.addEventListener("input",()=>{l.cwd=H.value});let X=_.createDiv({cls:"af-form-row"});X.createDiv({cls:"af-form-label",text:"Timeout (sec)"});let me=X.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",value:String(t.timeout)}});me.addEventListener("input",()=>{let I=parseInt(me.value,10);!isNaN(I)&&I>0&&(l.timeout=I)});let ee=_.createDiv({cls:"af-form-row"});ee.createDiv({cls:"af-form-label",text:"Permission Mode"});let J=ee.createEl("select",{cls:"af-form-select"}),K=_.createDiv({cls:"af-form-hint",text:""});be=()=>{l.permissionMode=Aa(l.permissionMode,l.adapter);let I=_a(l.adapter);J.empty();for(let[te,ue]of I){let Ae=J.createEl("option",{text:ue,attr:{value:te}});te===l.permissionMode&&(Ae.selected=!0)}K.textContent=I.find(([te])=>te===J.value)?.[2]??""},be();let ke=_.createDiv({cls:"af-form-hint af-adapter-map-hint"});ke.setCssStyles({display:"none"}),ne=()=>{ke.setText(Zl(l.adapter)),ke.setCssStyles({display:""})},J.addEventListener("change",()=>{l.permissionMode=J.value,K.textContent=_a(l.adapter).find(([I])=>I===J.value)?.[2]??""});let je=_.createDiv({cls:"af-form-row"});je.createDiv({cls:"af-form-label",text:"Effort Level"});let Qe=je.createEl("select",{cls:"af-form-select"});for(let[I,te]of[["","Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]]){let ue=Qe.createEl("option",{text:te,attr:{value:I}});I===(t.effort??"")&&(ue.selected=!0)}Qe.addEventListener("change",()=>{l.effort=Qe.value}),_.createDiv({cls:"af-form-hint",text:"Controls reasoning depth \u2014 low is fast, max is most thorough"});let Ze=_.createDiv({cls:"af-form-row"}),He=Ze.createDiv({cls:"af-form-label",text:"Auto-compact at"});e.addTooltip(He,"Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.");let $=Ze.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",min:"0",max:"100",value:String(l.autoCompactThreshold)}});$.addEventListener("input",()=>{let I=parseInt($.value,10);!isNaN(I)&&I>=0&&I<=100&&(l.autoCompactThreshold=I)}),_.createDiv({cls:"af-form-hint",text:"0 disables auto-compact"});{let I=s.runtime.getSnapshot().agents.filter(te=>te.wikiKeeper!==void 0);if(I.length>0){let te=_.createDiv({cls:"af-form-row af-form-row-toggle"}),ue=te.createDiv({cls:"af-form-label",text:"Wiki access"});e.addTooltip(ue,"Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).");let Ae=te.createDiv({cls:"af-form-field-wrap"});for(let Ne of I){let Ct=Ae.createEl("label",{cls:"af-form-checkbox-row"}),Be=Ct.createEl("input",{attr:{type:"checkbox"}});l.wikiReferences.includes(Ne.name)&&(Be.checked=!0),Ct.createSpan({text:` ${Ne.name}`,cls:"af-form-checkbox-label"}),Be.addEventListener("change",()=>{Be.checked?l.wikiReferences.includes(Ne.name)||l.wikiReferences.push(Ne.name):l.wikiReferences=l.wikiReferences.filter(Vt=>Vt!==Ne.name)})}}}if(t.isFolder){let I=c.createDiv({cls:"af-create-section"}),te=I.createDiv({cls:"af-create-section-header"}),ue=te.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(ue,"heart-pulse");let Ae=te.createSpan({text:"Heartbeat"});e.addTooltip(Ae,"Autonomous periodic run \u2014 what the agent does when no one is asking");let Ne=I.createDiv({cls:"af-form-row af-form-row-toggle"});Ne.createDiv({cls:"af-form-label",text:"Enabled"});let Ct=Ne.createDiv({cls:`af-agent-card-toggle${l.heartbeatEnabled?" on":""}`}),Be=I.createDiv();Be.setCssStyles({display:l.heartbeatEnabled?"":"none"}),Ct.onclick=()=>{let vt=Ct.hasClass("on");Ct.toggleClass("on",!vt),l.heartbeatEnabled=!vt,Be.setCssStyles({display:vt?"none":""})},ec(Be,l);let Vt=Be.createDiv({cls:"af-form-row af-form-row-toggle"}),ct=Vt.createDiv({cls:"af-form-label"});ct.setText("Notify"),e.addTooltip(ct,"Show an Obsidian notice when the heartbeat completes");let Ma=Vt.createDiv({cls:`af-agent-card-toggle${l.heartbeatNotify?" on":""}`});Ma.onclick=()=>{let vt=Ma.hasClass("on");Ma.toggleClass("on",!vt),l.heartbeatNotify=!vt};let Fc=s.runtime.getSnapshot(),Gr=Be.createDiv({cls:"af-form-row"}),Vr=Gr.createDiv({cls:"af-form-label"});Vr.setText("Post to channel"),e.addTooltip(Vr,"Heartbeat results are posted to this Slack channel when the run completes");let yn=Gr.createEl("select",{cls:"af-form-select"});yn.createEl("option",{text:"(none)",attr:{value:""}});for(let vt of Fc.channels){let Lc=yn.createEl("option",{text:vt.name,attr:{value:vt.name}});vt.name===l.heartbeatChannel&&(Lc.selected=!0)}yn.addEventListener("change",()=>{l.heartbeatChannel=yn.value,Kr()});let Fa=Be.createDiv({cls:"af-form-row"}),Yr=Fa.createDiv({cls:"af-form-label"});Yr.setText("Target ID"),e.addTooltip(Yr,"Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel\u2019s first allowed user.");let La=Fa.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Channel/chat id \u2014 empty = DM"}});La.value=l.heartbeatChannelTarget,La.addEventListener("input",()=>{l.heartbeatChannelTarget=La.value.trim()});let Kr=()=>{Fa.setCssStyles({display:l.heartbeatChannel?"":"none"})};Kr();let vn=Be.createDiv({cls:"af-form-label"});vn.setCssStyles({width:"auto"}),vn.setCssStyles({marginTop:"12px"}),vn.setText("Instruction"),e.addTooltip(vn,'What the agent does on each heartbeat. Also used by the "Run Now" button.');let Oa=Be.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Check status, scan for issues, report findings...",rows:"8"}});Oa.value=l.heartbeatBody,Oa.addEventListener("input",()=>{l.heartbeatBody=Oa.value})}let ae=c.createDiv({cls:"af-create-section"}),ge=ae.createDiv({cls:"af-create-section-header"}),et=ge.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(et,"puzzle"),ge.createSpan({text:"Skills"});let xe=s.runtime.getSnapshot();if(xe.skills.length>0){ae.createDiv({cls:"af-form-sublabel",text:"Shared Skills"});let I=ae.createDiv({cls:"af-create-skills-grid"});for(let te of xe.skills){let ue=I.createDiv({cls:"af-create-skill-item"}),Ae=ue.createEl("input",{cls:"af-form-toggle",attr:{type:"checkbox"}});Ae.checked=l.selectedSkills.has(te.name),Ae.addEventListener("change",()=>{Ae.checked?l.selectedSkills.add(te.name):l.selectedSkills.delete(te.name)});let Ne=ue.createDiv({cls:"af-create-skill-label"});Ne.createSpan({cls:"af-create-skill-name",text:te.name}),te.description&&Ne.createSpan({cls:"af-create-skill-desc",text:` \u2014 ${te.description}`})}}let Ke=ae.createDiv({cls:"af-form-sublabel"});Ke.setText("Agent-specific skills"),e.addTooltip(Ke,"Custom skills/instructions only for this agent, not shared with others");let _e=ae.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Custom skills/instructions for this agent...",rows:"4"}});_e.value=t.skillsBody,_e.addEventListener("input",()=>{l.skillsBody=_e.value});let Le=c.createDiv({cls:"af-create-section"}),S=Le.createDiv({cls:"af-create-section-header"}),j=S.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(j,"plug");let ce=S.createSpan({text:"MCP Servers"});e.addTooltip(ce,"Grant agent access to MCP servers"),tc(Le,e,l.selectedMcpServers);let Se=c.createDiv({cls:"af-create-section"}),Oe=Se.createDiv({cls:"af-create-section-header"}),Wt=Oe.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(Wt,"file-text");let mn=Oe.createSpan({text:"Context"});e.addTooltip(mn,"Project-specific context included in every run");let ls=Se.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Background info, repo structure, conventions...",rows:"4"}});ls.value=t.contextBody,ls.addEventListener("input",()=>{l.contextBody=ls.value});let lt=c.createDiv({cls:"af-create-section"}),Fs=lt.createDiv({cls:"af-create-section-header"}),fn=Fs.createSpan({cls:"af-create-section-icon"});(0,de.setIcon)(fn,"shield-check"),Fs.createSpan({text:"Permissions"}),e.createFormField(lt,"Approval required","git_push, file_delete","Comma-separated tool names",I=>{l.approvalRequired=I},t.approvalRequired.join(", "));let cs=lt.createDiv({cls:"af-form-row"});cs.createDiv({cls:"af-form-label",text:"Allowed Commands"});let ds=cs.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(curl *) +Bash(python3 *) +Read +Edit +Write`,rows:"4"}});ds.value=t.permissionRules.allow.join(` +`),ds.addEventListener("input",()=>{l.allowedCommands=ds.value});let gn=lt.createDiv({cls:"af-form-row"});gn.createDiv({cls:"af-form-label",text:"Blocked Commands"});let Lt=gn.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(git push *) +Bash(rm -rf *) +Bash(sudo *)`,rows:"4"}});Lt.value=t.permissionRules.deny.join(` +`),Lt.addEventListener("input",()=>{l.blockedCommands=Lt.value}),lt.createDiv({cls:"af-form-hint",text:"On Codex agents these become execpolicy command rules \u2014 only Bash(cmd args *) prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are ignored, and file/network access is governed by Permission Mode (the sandbox)."});let L=lt.createDiv({cls:"af-form-row"});L.createDiv({cls:"af-form-label",text:"Memory enabled"});let V=L.createDiv({cls:`af-agent-card-toggle${t.memory?" on":""}`});V.onclick=()=>{let I=V.hasClass("on");V.toggleClass("on",!I),l.memory=!I};let De=lt.createDiv({cls:"af-form-row"});De.createDiv({cls:"af-form-label",text:"Memory token budget"});let Re=De.createEl("input",{cls:"af-create-input",attr:{type:"number",min:"200",step:"100"}});Re.value=String(l.memoryTokenBudget),Re.addEventListener("input",()=>{let I=parseInt(Re.value,10);Number.isFinite(I)&&(l.memoryTokenBudget=I)});let ye=lt.createDiv({cls:"af-form-row"});ye.createDiv({cls:"af-form-label",text:"Nightly reflection"});let Ue=ye.createDiv({cls:`af-agent-card-toggle${t.reflection.enabled?" on":""}`});Ue.onclick=()=>{let I=Ue.hasClass("on");Ue.toggleClass("on",!I),l.reflectionEnabled=!I};let $e=lt.createDiv({cls:"af-form-row"});$e.createDiv({cls:"af-form-label",text:"Propose skills from memory"});let Ot=$e.createDiv({cls:`af-agent-card-toggle${t.reflection.proposeSkills?" on":""}`});Ot.onclick=()=>{let I=Ot.hasClass("on");Ot.toggleClass("on",!I),l.reflectionProposeSkills=!I};let qt=a.createDiv({cls:"af-create-footer"}),zt=qt.createEl("button",{cls:"af-btn-sm danger"});T(zt,"trash-2","af-btn-icon"),zt.appendText(" Delete"),zt.onclick=()=>void s.deleteAgent(t.name),qt.createDiv({cls:"af-toolbar-spacer"});let Ia=qt.createEl("button",{cls:"af-btn-sm",text:"Cancel"});Ia.onclick=()=>e.navigate("agent-detail",t.name);let Gt=qt.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(Gt,"check","af-btn-icon"),Gt.appendText(" Save Changes"),Gt.onclick=async()=>{let I=ue=>ue.split(",").map(Ae=>Ae.trim()).filter(Boolean),te=e.markSubmitBusy(Gt,"Saving...","check","Save Changes");try{let ue=Ae=>re(Ae).map(Ne=>Ne.trim()).filter(Boolean);await s.repository.updateAgent(t.name,{description:l.description.trim(),avatar:l.avatar.trim(),tags:I(l.tags),systemPrompt:l.systemPrompt.trim(),model:l.model.trim()||"default",adapter:l.adapter,cwd:l.cwd.trim(),timeout:l.timeout,permissionMode:l.permissionMode,effort:l.effort||void 0,approvalRequired:I(l.approvalRequired),memory:l.memory,memoryTokenBudget:l.memoryTokenBudget,reflectionEnabled:l.reflectionEnabled,reflectionProposeSkills:l.reflectionProposeSkills,skills:Array.from(l.selectedSkills),mcpServers:Array.from(l.selectedMcpServers),skillsBody:l.skillsBody.trim(),contextBody:l.contextBody.trim(),enabled:l.enabled,permissionRules:{allow:ue(l.allowedCommands),deny:ue(l.blockedCommands)},autoCompactThreshold:l.autoCompactThreshold,wikiReferences:l.wikiReferences}),t.isFolder&&await s.repository.updateHeartbeat(t.name,{enabled:l.heartbeatEnabled,schedule:l.heartbeatSchedule.trim(),notify:l.heartbeatNotify,channel:l.heartbeatChannel,channelTarget:l.heartbeatChannel?l.heartbeatChannelTarget:"",body:l.heartbeatBody.trim()}),new de.Notice(`Agent "${t.name}" updated.`),await s.refreshFromVault(),e.navigate("agent-detail",t.name)}catch(ue){let Ae=ue instanceof Error?ue.message:String(ue);new de.Notice(`Failed to update agent: ${Ae}`),te()}}}var Ee=require("obsidian");function ac(a,e){let t=Ta(e.schedule),s=a.createDiv({cls:"af-form-row"});s.createDiv({cls:"af-form-label",text:"Frequency"});let n=s.createEl("select",{cls:"af-form-select"}),r=[["every_5m","Every 5 minutes"],["every_15m","Every 15 minutes"],["every_30m","Every 30 minutes"],["every_hour","Every hour"],["every_2h","Every 2 hours"],["daily","Daily"],["weekdays","Weekdays"],["weekly","Weekly"],["monthly","Monthly"]];for(let[y,v]of r){let k=n.createEl("option",{text:v,attr:{value:y}});y===t.freq&&(k.selected=!0)}let i=a.createDiv({cls:"af-form-row af-schedule-time-row"});i.createDiv({cls:"af-form-label",text:"Time"});let o=i.createDiv({cls:"af-schedule-time-selects"}),l=o.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let y=0;y<24;y++){let v=y>=12?"PM":"AM",k=y===0?12:y>12?y-12:y,b=l.createEl("option",{text:`${k} ${v}`,attr:{value:String(y)}});y===t.hour&&(b.selected=!0)}o.createSpan({cls:"af-schedule-colon",text:":"});let c=o.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let y=0;y<60;y+=5){let v=c.createEl("option",{text:String(y).padStart(2,"0"),attr:{value:String(y)}});y===t.minute&&(v.selected=!0)}let d=a.createDiv({cls:"af-form-row af-schedule-day-row"});d.createDiv({cls:"af-form-label",text:"Day"});let u=d.createDiv({cls:"af-schedule-day-buttons"}),h=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],p=new Set(t.days);for(let y=0;y<7;y++){let v=u.createEl("button",{cls:`af-schedule-day-btn${p.has(y)?" active":""}`,text:h[y]});v.onclick=()=>{p.has(y)?p.delete(y):p.add(y),v.toggleClass("active",p.has(y)),w()}}let m=a.createDiv({cls:"af-form-row af-schedule-dom-row"});m.createDiv({cls:"af-form-label",text:"Day of month"});let f=m.createEl("select",{cls:"af-form-select af-form-select-sm"});for(let y=1;y<=28;y++){let v=f.createEl("option",{text:String(y),attr:{value:String(y)}});y===t.dayOfMonth&&(v.selected=!0)}let g=()=>{let y=n.value,v=["daily","weekdays","weekly","monthly"].includes(y),k=y==="weekly",b=y==="monthly";i.setCssStyles({display:v?"":"none"}),d.setCssStyles({display:k?"":"none"}),m.setCssStyles({display:b?"":"none"})},w=()=>{let y=n.value,v=l.value,k=c.value,b="";switch(y){case"every_5m":b="*/5 * * * *";break;case"every_15m":b="*/15 * * * *";break;case"every_30m":b="*/30 * * * *";break;case"every_hour":b="0 * * * *";break;case"every_2h":b="0 */2 * * *";break;case"daily":b=`${k} ${v} * * *`;break;case"weekdays":b=`${k} ${v} * * 1-5`;break;case"weekly":{let P=Array.from(p).sort().join(",")||"1";b=`${k} ${v} * * ${P}`;break}case"monthly":b=`${k} ${v} ${f.value} * *`;break}e.schedule=b,e.type="recurring"};n.addEventListener("change",()=>{g(),w()}),l.addEventListener("change",w),c.addEventListener("change",w),f.addEventListener("change",w),g()}function rc(a,e,t,s){let n=a.createDiv({cls:"af-form-row"}),r=n.createDiv({cls:"af-form-label",text:"Channel"}),i=n.createEl("select",{cls:"af-form-select"});i.createEl("option",{text:"\u2014 none (run log only) \u2014",attr:{value:""}});for(let u of t){let h=i.createEl("option",{text:u.name,attr:{value:u.name}});u.name===s.channel&&(h.selected=!0)}e.addTooltip(r,"Post this task\u2019s full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).");let o=a.createDiv({cls:"af-form-row"}),l=o.createDiv({cls:"af-form-label",text:"Target ID"}),c=o.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Discord/Slack channel ID or Telegram chat ID \u2014 empty = DM you"}});c.value=s.channelTarget,c.addEventListener("input",()=>{s.channelTarget=c.value.trim()}),e.addTooltip(l,"Where in the channel to post. For Discord, enable Developer Mode and right-click the channel \u2192 Copy Channel ID. Leave empty to DM the first allowed user instead.");let d=()=>{o.setCssStyles({display:s.channel?"":"none"})};d(),i.addEventListener("change",()=>{s.channel=i.value,d()})}function Zh(a){let e=t=>String(t).padStart(2,"0");return`${a.getFullYear()}-${e(a.getMonth()+1)}-${e(a.getDate())}T${e(a.getHours())}:${e(a.getMinutes())}:${e(a.getSeconds())}`}function jr(a){let e=t=>String(t).padStart(2,"0");return`${a.getFullYear()}-${e(a.getMonth()+1)}-${e(a.getDate())}T${e(a.getHours())}:${e(a.getMinutes())}`}function ic(a,e,t){let s=a.createDiv({cls:"af-form-row"});s.createDiv({cls:"af-form-label",text:"Run"});let n=s.createDiv({cls:"af-segmented"}),r=[["immediate","Immediate"],["recurring","Recurring"],["once","One-time"]],i=[];for(let[o,l]of r){let c=n.createEl("button",{cls:`af-segmented-btn${o===e?" active":""}`,text:l});i.push([o,c]),c.onclick=()=>{for(let[d,u]of i)u.toggleClass("active",d===o);t(o)}}}function oc(a,e){let{plugin:t}=e,s=t.runtime.getSnapshot(),n=a.createDiv({cls:"af-detail-header"}),r=n.createDiv({cls:"af-detail-header-left"}),i=r.createDiv({cls:"af-agent-card-avatar idle"});(0,Ee.setIcon)(i,"plus");let o=r.createDiv();o.createDiv({cls:"af-detail-header-name",text:"Create New Task"}),o.createDiv({cls:"af-detail-header-desc",text:"Configure a new task for your fleet"}),n.createDiv({cls:"af-detail-header-actions"});let l={title:"",agent:s.agents[0]?.name??"",priority:"medium",tags:"",body:"",scheduleEnabled:!1,scheduleMode:"recurring",schedule:"0 9 * * *",runAt:"",type:"immediate",enabled:!0,catchUp:!0,effort:"",model:"",channel:"",channelTarget:""},c=a.createDiv({cls:"af-create-form"}),d=c.createDiv({cls:"af-create-section"}),u=d.createDiv({cls:"af-create-section-header"}),h=u.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(h,"file-text"),u.createSpan({text:"Task Details"}),e.createFormField(d,"Title","Daily status report","Used as the task identifier",$=>{l.title=$});let p=d.createDiv({cls:"af-form-row"});p.createDiv({cls:"af-form-label",text:"Agent"});let m=p.createEl("select",{cls:"af-form-select"});for(let $ of s.agents)m.createEl("option",{text:$.name,attr:{value:$.name}});m.addEventListener("change",()=>{l.agent=m.value});let f=d.createDiv({cls:"af-form-row"});f.createDiv({cls:"af-form-label",text:"Priority"});let g=f.createEl("select",{cls:"af-form-select"}),w=[["low","Low"],["medium","Medium"],["high","High"],["critical","Critical"]];for(let[$,ae]of w){let ge=g.createEl("option",{text:ae,attr:{value:$}});$==="medium"&&(ge.selected=!0)}g.addEventListener("change",()=>{l.priority=g.value}),e.createFormField(d,"Tags","monitoring, devops","Comma-separated",$=>{l.tags=$});let y=c.createDiv({cls:"af-create-section"}),v=y.createDiv({cls:"af-create-section-header"}),k=v.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(k,"message-square"),v.createSpan({text:"Instructions"});let b=y.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Describe what the agent should do...",rows:"10"}});b.addEventListener("input",()=>{l.body=b.value});let P=c.createDiv({cls:"af-create-section"}),A=P.createDiv({cls:"af-create-section-header"}),M=A.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(M,"clock"),A.createSpan({text:"Schedule"}),ic(P,"immediate",$=>{l.scheduleEnabled=$!=="immediate",$!=="immediate"&&(l.scheduleMode=$),l.type=$,E.setCssStyles({display:$==="immediate"?"none":""}),x.setCssStyles({display:$==="recurring"?"":"none"}),_.setCssStyles({display:$==="once"?"":"none"})});let E=P.createDiv({cls:"af-schedule-body"});E.setCssStyles({display:"none"});let x=E.createDiv(),_=E.createDiv();_.setCssStyles({display:"none"}),ac(x,l);let R=_.createDiv({cls:"af-form-row"});R.createDiv({cls:"af-form-label",text:"Run at"});let O=R.createEl("input",{cls:"af-form-input",attr:{type:"datetime-local",value:jr(new Date(Date.now()+36e5))}});l.runAt=new Date(O.value).toISOString(),O.addEventListener("input",()=>{l.runAt=O.value?new Date(O.value).toISOString():""});let F=E.createDiv({cls:"af-form-row af-form-row-toggle"});F.createDiv({cls:"af-form-label",text:"Enabled"});let B=F.createDiv({cls:"af-agent-card-toggle on"});B.onclick=()=>{let $=B.hasClass("on");B.toggleClass("on",!$),l.enabled=!$};let q=E.createDiv({cls:"af-form-row af-form-row-toggle"});q.createDiv({cls:"af-form-label"}).setText("Catch up if missed");let be=q.createDiv({cls:`af-agent-card-toggle${l.catchUp?" on":""}`});be.onclick=()=>{let $=be.hasClass("on");be.toggleClass("on",!$),l.catchUp=!$};let ne=c.createDiv({cls:"af-create-section"}),Y=ne.createDiv({cls:"af-create-section-header"}),H=Y.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(H,"gauge"),Y.createSpan({text:"Execution"});let X=ne.createDiv({cls:"af-form-row"}),me=X.createDiv({cls:"af-form-label",text:"Model"}),ee=X.createDiv({cls:"af-form-field-wrap"}),J=$=>{ee.empty();let ae=s.agents.find(ge=>ge.name===$);Nt(ee,{value:l.model,adapter:ae?.adapter,onChange:ge=>{l.model=ge},allowInherit:!0,inheritPlaceholder:ae?`Inherit from ${ae.name}${ae.model?` (${ae.model})`:""}`:"Inherit from agent"})};J(l.agent),m.addEventListener("change",()=>J(m.value)),e.addTooltip(me,"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.");let K=ne.createDiv({cls:"af-form-row"}),ke=K.createDiv({cls:"af-form-label",text:"Effort"}),je=K.createEl("select",{cls:"af-form-select"});for(let[$,ae]of[["","Agent Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]]){let ge=je.createEl("option",{text:ae,attr:{value:$}});$===l.effort&&(ge.selected=!0)}je.addEventListener("change",()=>{l.effort=je.value}),rc(ne,e,s.channels,l),e.addTooltip(ke,"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.");let Qe=a.createDiv({cls:"af-create-footer"}),Ze=Qe.createEl("button",{cls:"af-btn-sm",text:"Cancel"});Ze.onclick=()=>e.navigate("kanban");let He=Qe.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(He,"plus","af-btn-icon"),He.appendText(" Create Task"),He.onclick=async()=>{let $=l.title.trim();if(!$){new Ee.Notice("Task title is required.");return}let ae=z($),ge=_e=>_e.split(",").map(Le=>Le.trim()).filter(Boolean),et=l.scheduleEnabled?l.scheduleMode==="once"?"once":"recurring":"immediate",xe={task_id:ae,agent:l.agent,type:et,priority:l.priority,enabled:l.enabled,created:Zh(new Date),run_count:0,catch_up:l.catchUp,effort:l.effort||void 0,model:l.model||void 0,channel:l.channel||void 0,channel_target:l.channel&&l.channelTarget?l.channelTarget:void 0,tags:ge(l.tags)};if(et==="recurring")xe.schedule=l.schedule.trim()||"0 9 * * *";else if(et==="once"){if(!l.runAt){new Ee.Notice("Pick a date/time for the one-time run.");return}xe.run_at=l.runAt}let Ke=e.markSubmitBusy(He,"Creating...","plus","Create Task");try{let _e=await t.repository.getAvailablePath(t.repository.getSubfolder("tasks"),ae);await t.app.vault.create(_e,U(xe,l.body.trim()||"Describe the task here.")),new Ee.Notice(`Task "${ae}" created.`),await t.refreshFromVault(),e.navigate("task-detail",ae)}catch(_e){let Le=_e instanceof Error?_e.message:String(_e);new Ee.Notice(`Failed to create task: ${Le}`),Ke()}}}function lc(a,e,t){let{plugin:s}=e,n=s.runtime.getSnapshot(),r=a.createDiv({cls:"af-detail-header"}),i=r.createDiv({cls:"af-detail-header-left"}),o=i.createDiv({cls:"af-agent-card-avatar idle"});(0,Ee.setIcon)(o,"edit");let l=i.createDiv();l.createDiv({cls:"af-detail-header-name",text:`Edit Task: ${t.taskId}`}),l.createDiv({cls:"af-detail-header-desc",text:"Modify task configuration"}),r.createDiv({cls:"af-detail-header-actions"});let c=!!(t.schedule||t.runAt),d={agent:t.agent,type:t.type,priority:t.priority,schedule:t.schedule??"0 9 * * *",runAt:t.runAt??"",scheduleEnabled:c,scheduleMode:t.type==="once"?"once":"recurring",enabled:t.enabled,catchUp:t.catchUp,effort:t.effort??"",model:t.model??"",channel:t.channel??"",channelTarget:t.channelTarget??"",tags:t.tags.join(", "),body:t.body},u=a.createDiv({cls:"af-create-form"}),h=u.createDiv({cls:"af-create-section"}),p=h.createDiv({cls:"af-create-section-header"}),m=p.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(m,"file-text"),p.createSpan({text:"Task Details"});let f=h.createDiv({cls:"af-form-row"});f.createDiv({cls:"af-form-label",text:"Title"}),f.createEl("input",{cls:"af-form-input",attr:{type:"text",value:t.taskId,disabled:"true"}}).setCssStyles({opacity:"0.6"});let w=h.createDiv({cls:"af-form-row"});w.createDiv({cls:"af-form-label",text:"Agent"});let y=w.createEl("select",{cls:"af-form-select"});for(let S of n.agents){let j=y.createEl("option",{text:S.name,attr:{value:S.name}});S.name===t.agent&&(j.selected=!0)}y.addEventListener("change",()=>{d.agent=y.value});let v=h.createDiv({cls:"af-form-row"});v.createDiv({cls:"af-form-label",text:"Priority"});let k=v.createEl("select",{cls:"af-form-select"}),b=[["low","Low"],["medium","Medium"],["high","High"],["critical","Critical"]];for(let[S,j]of b){let ce=k.createEl("option",{text:j,attr:{value:S}});S===t.priority&&(ce.selected=!0)}k.addEventListener("change",()=>{d.priority=k.value}),e.createFormField(h,"Tags","monitoring, critical","Comma-separated",S=>{d.tags=S},t.tags.join(", "));let P=u.createDiv({cls:"af-create-section"}),A=P.createDiv({cls:"af-create-section-header"}),M=A.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(M,"message-square"),A.createSpan({text:"Instructions"});let E=P.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Describe what the agent should do...",rows:"10"}});E.value=t.body,E.addEventListener("input",()=>{d.body=E.value});let x=u.createDiv({cls:"af-create-section"}),_=x.createDiv({cls:"af-create-section-header"}),R=_.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(R,"clock"),_.createSpan({text:"Schedule"});let O=c?d.scheduleMode==="once"?"once":"recurring":"immediate";ic(x,O,S=>{d.scheduleEnabled=S!=="immediate",S!=="immediate"&&(d.scheduleMode=S),d.type=S,F.setCssStyles({display:S==="immediate"?"none":""}),B.setCssStyles({display:S==="recurring"?"":"none"}),q.setCssStyles({display:S==="once"?"":"none"})});let F=x.createDiv({cls:"af-schedule-body"});F.setCssStyles({display:c?"":"none"});let B=F.createDiv(),q=F.createDiv();B.setCssStyles({display:O==="recurring"?"":"none"}),q.setCssStyles({display:O==="once"?"":"none"}),ac(B,d);let oe=q.createDiv({cls:"af-form-row"});oe.createDiv({cls:"af-form-label",text:"Run at"});let be=d.runAt?jr(new Date(d.runAt)):jr(new Date(Date.now()+36e5)),ne=oe.createEl("input",{cls:"af-form-input",attr:{type:"datetime-local",value:be}});d.runAt||(d.runAt=new Date(ne.value).toISOString()),ne.addEventListener("input",()=>{d.runAt=ne.value?new Date(ne.value).toISOString():""});let Y=F.createDiv({cls:"af-form-row af-form-row-toggle"});Y.createDiv({cls:"af-form-label",text:"Enabled"});let H=Y.createDiv({cls:`af-agent-card-toggle${t.enabled?" on":""}`});H.onclick=()=>{let S=H.hasClass("on");H.toggleClass("on",!S),d.enabled=!S};let X=F.createDiv({cls:"af-form-row af-form-row-toggle"});X.createDiv({cls:"af-form-label"}).setText("Catch up if missed");let ee=X.createDiv({cls:`af-agent-card-toggle${d.catchUp?" on":""}`});ee.onclick=()=>{let S=ee.hasClass("on");ee.toggleClass("on",!S),d.catchUp=!S};let J=u.createDiv({cls:"af-create-section"}),K=J.createDiv({cls:"af-create-section-header"}),ke=K.createSpan({cls:"af-create-section-icon"});(0,Ee.setIcon)(ke,"gauge"),K.createSpan({text:"Execution"});let je=J.createDiv({cls:"af-form-row"}),Qe=je.createDiv({cls:"af-form-label",text:"Effort"}),Ze=je.createEl("select",{cls:"af-form-select"}),He=[["","Agent Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]];for(let[S,j]of He){let ce=Ze.createEl("option",{text:j,attr:{value:S}});S===d.effort&&(ce.selected=!0)}Ze.addEventListener("change",()=>{d.effort=Ze.value}),e.addTooltip(Qe,"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.");let $=J.createDiv({cls:"af-form-row"}),ae=$.createDiv({cls:"af-form-label",text:"Model"}),ge=$.createDiv({cls:"af-form-field-wrap"}),et=S=>{ge.empty();let j=n.agents.find(ce=>ce.name===S);Nt(ge,{value:d.model,adapter:j?.adapter,onChange:ce=>{d.model=ce},allowInherit:!0,inheritPlaceholder:j?`Inherit from ${j.name}${j.model?` (${j.model})`:""}`:"Inherit from agent"})};et(d.agent),y.addEventListener("change",()=>et(y.value)),rc(J,e,n.channels,d),e.addTooltip(ae,"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.");let xe=a.createDiv({cls:"af-create-footer"}),Ke=xe.createEl("button",{cls:"af-btn-sm danger"});T(Ke,"trash-2","af-btn-icon"),Ke.appendText(" Delete"),Ke.onclick=()=>{new ut(s.app,{title:`Delete task "${t.taskId}"?`,body:"The task file will be moved to your system trash and can be recovered.",confirmText:"Delete",danger:!0,onConfirm:async()=>{await s.repository.deleteTask(t.taskId),new Ee.Notice(`Task "${t.taskId}" deleted.`),await new Promise(S=>window.setTimeout(S,200)),await s.refreshFromVault(),e.navigate("kanban")}}).open()},xe.createDiv({cls:"af-toolbar-spacer"});let _e=xe.createEl("button",{cls:"af-btn-sm",text:"Cancel"});_e.onclick=()=>e.navigate("task-detail",t.taskId);let Le=xe.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(Le,"check","af-btn-icon"),Le.appendText(" Save Changes"),Le.onclick=async()=>{let S=Se=>Se.split(",").map(Oe=>Oe.trim()).filter(Boolean),j=d.scheduleEnabled?d.scheduleMode==="once"?"once":"recurring":"immediate";if(j==="once"&&!d.runAt){new Ee.Notice("Pick a date/time for the one-time run.");return}let ce=e.markSubmitBusy(Le,"Saving...","check","Save Changes");try{await s.repository.updateTask(t.taskId,{agent:d.agent,type:j,priority:d.priority,schedule:j==="recurring"?d.schedule.trim():"",runAt:j==="once"?d.runAt:"",enabled:d.enabled,catch_up:d.catchUp,effort:d.effort||void 0,model:d.model||"",channel:d.channel||"",channelTarget:d.channel&&d.channelTarget?d.channelTarget:"",tags:S(d.tags),body:d.body.trim()}),new Ee.Notice(`Task "${t.taskId}" updated.`),await s.refreshFromVault(),e.navigate("task-detail",t.taskId)}catch(Se){let Oe=Se instanceof Error?Se.message:String(Se);new Ee.Notice(`Failed to update task: ${Oe}`),ce()}}}var Ve=require("obsidian");function Hr(a,e,t){let{plugin:s}=e,n=a.createDiv({cls:"af-detail-header"}),r=n.createDiv({cls:"af-detail-header-left"}),i=r.createDiv({cls:"af-agent-card-avatar idle"});(0,Ve.setIcon)(i,t?"edit":"plus");let o=r.createDiv();t?(o.createDiv({cls:"af-detail-header-name",text:`Edit Skill: ${t.name}`}),o.createDiv({cls:"af-detail-header-desc",text:"Modify skill definition"})):(o.createDiv({cls:"af-detail-header-name",text:"Create New Skill"}),o.createDiv({cls:"af-detail-header-desc",text:"Define a reusable skill for your agents"})),n.createDiv({cls:"af-detail-header-actions"});let l={name:"",description:t?t.description??"":"",tags:t?t.tags.join(", "):"",body:t?t.body:"",toolsBody:t?t.toolsBody:"",referencesBody:t?t.referencesBody:"",examplesBody:t?t.examplesBody:""},c={none:{label:"None",prompt:""},cli:{label:"CLI Tool Wrapper",prompt:`You are using the {{tool}} CLI. All operations go through the wrapper script. Requirements: - Ensure required environment variables are set @@ -12010,39 +12002,34 @@ Key behaviors: - Use tables and charts where appropriate - Always state the time range and filters applied - Flag anomalies and outliers explicitly -- End with actionable insights, not just observations`}},d=s.createDiv({cls:"af-create-form"}),u=d.createDiv({cls:"af-create-section"}),p=u.createDiv({cls:"af-create-section-header"}),m=p.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(m,"puzzle"),p.createSpan({text:"Identity"}),this.createFormField(u,"Name","todoist","Unique identifier (will be slugified)",K=>{l.name=K}),this.createFormField(u,"Description","Manage tasks and projects via CLI","",K=>{l.description=K}),this.createFormField(u,"Tags","productivity, tasks","Comma-separated",K=>{l.tags=K});let f=d.createDiv({cls:"af-create-section"}),g=f.createDiv({cls:"af-create-section-header"}),y=g.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(y,"file-text"),g.createSpan({text:"Core Instructions"});let v=f.createDiv({cls:"af-form-row"});v.createDiv({cls:"af-form-label",text:"Template"});let k=v.createEl("select",{cls:"af-form-select"});for(let[K,{label:ne}]of Object.entries(h))k.createEl("option",{text:ne,attr:{value:K}});let w=f.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Skill instructions \u2014 what does this skill do and how should agents use it?",rows:"10"}});w.addEventListener("input",()=>{l.body=w.value}),k.addEventListener("change",()=>{let K=h[k.value];K&&k.value!=="none"&&(l.body=K.prompt,w.value=K.prompt)});let S=d.createDiv({cls:"af-create-section"}),T=S.createDiv({cls:"af-create-section-header"}),_=T.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(_,"wrench");let D=T.createSpan({text:"Tools"});this.addTooltip(D,"CLI commands, API endpoints, and tool definitions available to agents using this skill");let O=S.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`## Commands +- End with actionable insights, not just observations`}},d=a.createDiv({cls:"af-create-form"}),u=d.createDiv({cls:"af-create-section"}),h=u.createDiv({cls:"af-create-section-header"}),p=h.createSpan({cls:"af-create-section-icon"});if((0,Ve.setIcon)(p,"puzzle"),h.createSpan({text:"Identity"}),t){let H=u.createDiv({cls:"af-form-row"});H.createDiv({cls:"af-form-label",text:"Name"}),H.createEl("input",{cls:"af-form-input",attr:{type:"text",value:t.name,disabled:"true"}}).setCssStyles({opacity:"0.6"})}else e.createFormField(u,"Name","todoist","Unique identifier (will be slugified)",H=>{l.name=H});e.createFormField(u,"Description","Manage tasks and projects via CLI","",H=>{l.description=H},t?t.description??"":void 0),e.createFormField(u,"Tags","productivity, tasks","Comma-separated",H=>{l.tags=H},t?t.tags.join(", "):void 0);let m=d.createDiv({cls:"af-create-section"}),f=m.createDiv({cls:"af-create-section-header"}),g=f.createSpan({cls:"af-create-section-icon"});(0,Ve.setIcon)(g,"file-text"),f.createSpan({text:"Core Instructions"});let w;if(!t){let H=m.createDiv({cls:"af-form-row"});H.createDiv({cls:"af-form-label",text:"Template"}),w=H.createEl("select",{cls:"af-form-select"});for(let[X,{label:me}]of Object.entries(c))w.createEl("option",{text:me,attr:{value:X}})}let y=m.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:t?"Skill instructions...":"Skill instructions \u2014 what does this skill do and how should agents use it?",rows:"10"}});if(t&&(y.value=t.body),y.addEventListener("input",()=>{l.body=y.value}),w){let H=w;H.addEventListener("change",()=>{let X=c[H.value];X&&H.value!=="none"&&(l.body=X.prompt,y.value=X.prompt)})}let v=d.createDiv({cls:"af-create-section"}),k=v.createDiv({cls:"af-create-section-header"}),b=k.createSpan({cls:"af-create-section-icon"});(0,Ve.setIcon)(b,"wrench");let P=k.createSpan({text:"Tools"});e.addTooltip(P,"CLI commands, API endpoints, and tool definitions available to agents using this skill");let A=v.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:t?`## Commands + +### list +...`:`## Commands ### list Usage: tool list [--filter ] -...`,rows:"8"}});O.addEventListener("input",()=>{l.toolsBody=O.value});let C=d.createDiv({cls:"af-create-section"}),E=C.createDiv({cls:"af-create-section-header"}),P=E.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(P,"book-open");let N=E.createSpan({text:"References"});this.addTooltip(N,"Background docs, conventions, cheat sheets");let M=C.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"API docs, filter syntax, conventions...",rows:"6"}});M.addEventListener("input",()=>{l.referencesBody=M.value});let U=d.createDiv({cls:"af-create-section"}),$=U.createDiv({cls:"af-create-section-header"}),Z=$.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Z,"message-circle");let de=$.createSpan({text:"Examples"});this.addTooltip(de,"Example prompts and ideal outputs showing how to use this skill");let me=U.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`## Example: List all tasks +...`,rows:"8"}});t&&(A.value=t.toolsBody),A.addEventListener("input",()=>{l.toolsBody=A.value});let M=d.createDiv({cls:"af-create-section"}),E=M.createDiv({cls:"af-create-section-header"}),x=E.createSpan({cls:"af-create-section-icon"});(0,Ve.setIcon)(x,"book-open");let _=E.createSpan({text:"References"});e.addTooltip(_,"Background docs, conventions, cheat sheets");let R=M.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"API docs, filter syntax, conventions...",rows:"6"}});t&&(R.value=t.referencesBody),R.addEventListener("input",()=>{l.referencesBody=R.value});let O=d.createDiv({cls:"af-create-section"}),F=O.createDiv({cls:"af-create-section-header"}),B=F.createSpan({cls:"af-create-section-icon"});(0,Ve.setIcon)(B,"message-circle");let q=F.createSpan({text:"Examples"});e.addTooltip(q,"Example prompts and ideal outputs showing how to use this skill");let oe=O.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:t?`## Example: List all tasks +...`:`## Example: List all tasks User: Show me my tasks for today -Agent: ...`,rows:"6"}});me.addEventListener("input",()=>{l.examplesBody=me.value});let ge=s.createDiv({cls:"af-create-footer"}),X=ge.createEl("button",{cls:"af-btn-sm",text:"Cancel"});X.onclick=()=>this.navigate("skills");let W=ge.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(W,"plus","af-btn-icon"),W.appendText(" Create Skill"),W.onclick=async()=>{let K=l.name.trim();if(!K){new b.Notice("Skill name is required.");return}let ne=oe(K);if(this.plugin.repository.getSkillByName(ne)){new b.Notice(`Skill "${ne}" already exists.`);return}let G=ee=>ee.split(",").map(Q=>Q.trim()).filter(Boolean);try{await this.plugin.repository.createSkillFolder({name:ne,description:l.description.trim(),tags:G(l.tags),body:l.body.trim(),toolsBody:l.toolsBody.trim(),referencesBody:l.referencesBody.trim(),examplesBody:l.examplesBody.trim()}),new b.Notice(`Skill "${ne}" created.`),await this.plugin.refreshFromVault(),this.navigate("skills")}catch(ee){let Q=ee instanceof Error?ee.message:String(ee);new b.Notice(`Failed to create skill: ${Q}`)}}}renderEditAgentPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"bot","No agent selected","");return}let a=this.plugin.runtime.getSnapshot().agents.find(L=>L.name===n);if(!a){this.renderEmptyState(s,"bot","Agent not found",`Agent "${n}" was not found`);return}let i=s.createDiv({cls:"af-detail-header"}),o=i.createDiv({cls:"af-detail-header-left"}),c=o.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(c,"edit");let l=o.createDiv();l.createDiv({cls:"af-detail-header-name",text:`Edit Agent: ${a.name}`}),l.createDiv({cls:"af-detail-header-desc",text:"Modify agent configuration"});let h=i.createDiv({cls:"af-detail-header-actions"}),d={name:a.name,description:a.description??"",avatar:a.avatar,tags:a.tags.join(", "),systemPrompt:a.body,model:a.model,adapter:a.adapter,cwd:a.cwd??"",timeout:a.timeout,permissionMode:a.permissionMode,effort:a.effort??"",selectedSkills:new Set(a.skills),selectedMcpServers:new Set(a.mcpServers??[]),skillsBody:a.skillsBody,contextBody:a.contextBody,approvalRequired:a.approvalRequired.join(", "),memory:a.memory,memoryTokenBudget:a.memoryTokenBudget,reflectionEnabled:a.reflection.enabled,reflectionProposeSkills:a.reflection.proposeSkills,enabled:a.enabled,allowedCommands:a.permissionRules.allow.join(` -`),blockedCommands:a.permissionRules.deny.join(` -`),heartbeatEnabled:a.heartbeatEnabled,heartbeatSchedule:a.heartbeatSchedule,heartbeatBody:a.heartbeatBody,heartbeatNotify:a.heartbeatNotify,heartbeatChannel:a.heartbeatChannel,heartbeatChannelTarget:a.heartbeatChannelTarget,autoCompactThreshold:a.autoCompactThreshold??85,wikiReferences:(a.wikiReferences??[]).map(L=>L.agent)},u=s.createDiv({cls:"af-create-form"}),p=u.createDiv({cls:"af-create-section"}),m=p.createDiv({cls:"af-create-section-header"}),f=m.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(f,"user"),m.createSpan({text:"Identity"});let g=p.createDiv({cls:"af-form-row"});g.createDiv({cls:"af-form-label",text:"Name"}),g.createEl("input",{cls:"af-form-input",attr:{type:"text",value:a.name,disabled:"true"}}).setCssStyles({opacity:"0.6"}),this.createFormField(p,"Description","Monitors deployments and reports status","",L=>{d.description=L},a.description??"");let v=p.createDiv({cls:"af-form-row"});v.createDiv({cls:"af-form-label",text:"Avatar"});let k=v.createEl("button",{cls:"af-avatar-picker-btn"}),w=k.createDiv({cls:"af-avatar-picker-preview"});this.renderAgentAvatar(w,{...a,avatar:d.avatar??a.avatar}),k.createSpan({cls:"af-avatar-picker-label",text:d.avatar||a.avatar||"Pick icon\u2026"}),k.addEventListener("click",()=>{new $n(this.app,d.avatar??a.avatar,L=>{d.avatar=L,w.empty(),(0,b.setIcon)(w,L),k.querySelector(".af-avatar-picker-label")?.setText(L)}).open()}),this.createFormField(p,"Tags","devops, monitoring","Comma-separated",L=>{d.tags=L},a.tags.join(", "));let S=p.createDiv({cls:"af-form-row"});S.createDiv({cls:"af-form-label",text:"Enabled"});let T=S.createDiv({cls:`af-agent-card-toggle${a.enabled?" on":""}`});T.onclick=()=>{let L=T.hasClass("on");T.toggleClass("on",!L),d.enabled=!L};let _=u.createDiv({cls:"af-create-section"}),D=_.createDiv({cls:"af-create-section-header"}),O=D.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(O,"message-square"),D.createSpan({text:"System Prompt"});let C=_.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"System prompt...",rows:"10"}});C.value=a.body,C.addEventListener("input",()=>{d.systemPrompt=C.value});let E=u.createDiv({cls:"af-create-section"}),P=E.createDiv({cls:"af-create-section-header"}),N=P.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(N,"settings"),P.createSpan({text:"Runtime Config"});let M=E.createDiv({cls:"af-create-config-grid"}),U=M.createDiv({cls:"af-form-row"});U.createDiv({cls:"af-form-label",text:"Adapter"});let $=U.createEl("select",{cls:"af-form-select"});for(let[L,Y,Te]of Xo){let Le=$.createEl("option",{text:Y,attr:{value:L,...Te?{disabled:"true"}:{}}});(L===a.adapter||Ls(a.adapter)&&L==="codex")&&(Le.selected=!0)}let Z=M.createDiv({cls:"af-form-row"}),de=Z.createDiv({cls:"af-form-label",text:"Model"});this.addTooltip(de,`Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${this.plugin.settings.defaultModel||"CLI default"}).`);let me=Z.createDiv({cls:"af-form-field-wrap"}),ge=()=>{Rt(me,{value:d.model,adapter:d.adapter,onChange:L=>{d.model=L}})};ge();let X=()=>{};$.addEventListener("change",()=>{d.adapter=$.value,(Ls(d.adapter)?Qs:Zs).some(Y=>Y.value===d.model.trim())&&(d.model=""),ge(),d.permissionMode=Hn(d.permissionMode,d.adapter),X()});let W=M.createDiv({cls:"af-form-row"});W.createDiv({cls:"af-form-label",text:"Working Dir"});let K=W.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Leave empty for vault root",value:a.cwd??""}});K.addEventListener("input",()=>{d.cwd=K.value});let ne=M.createDiv({cls:"af-form-row"});ne.createDiv({cls:"af-form-label",text:"Timeout (sec)"});let G=ne.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",value:String(a.timeout)}});G.addEventListener("input",()=>{let L=parseInt(G.value,10);!isNaN(L)&&L>0&&(d.timeout=L)});let ee=M.createDiv({cls:"af-form-row"});ee.createDiv({cls:"af-form-label",text:"Permission Mode"});let Q=ee.createEl("select",{cls:"af-form-select"}),ae=M.createDiv({cls:"af-form-hint",text:""});X=()=>{d.permissionMode=Hn(d.permissionMode,d.adapter);let L=Wn(d.adapter);Q.empty();for(let[Y,Te]of L){let Le=Q.createEl("option",{text:Te,attr:{value:Y}});Y===d.permissionMode&&(Le.selected=!0)}ae.textContent=L.find(([Y])=>Y===Q.value)?.[2]??""},X(),Q.addEventListener("change",()=>{d.permissionMode=Q.value,ae.textContent=Wn(d.adapter).find(([L])=>L===Q.value)?.[2]??""});let ve=M.createDiv({cls:"af-form-row"});ve.createDiv({cls:"af-form-label",text:"Effort Level"});let Pe=ve.createEl("select",{cls:"af-form-select"});for(let[L,Y]of[["","Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]]){let Te=Pe.createEl("option",{text:Y,attr:{value:L}});L===(a.effort??"")&&(Te.selected=!0)}Pe.addEventListener("change",()=>{d.effort=Pe.value}),M.createDiv({cls:"af-form-hint",text:"Controls reasoning depth \u2014 low is fast, max is most thorough"});let Ie=M.createDiv({cls:"af-form-row"}),Me=Ie.createDiv({cls:"af-form-label",text:"Auto-compact at"});this.addTooltip(Me,"Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.");let Fe=Ie.createEl("input",{cls:"af-form-input af-form-input-sm",attr:{type:"number",min:"0",max:"100",value:String(d.autoCompactThreshold)}});Fe.addEventListener("input",()=>{let L=parseInt(Fe.value,10);!isNaN(L)&&L>=0&&L<=100&&(d.autoCompactThreshold=L)}),M.createDiv({cls:"af-form-hint",text:"0 disables auto-compact"});{let L=this.plugin.runtime.getSnapshot().agents.filter(Y=>Y.wikiKeeper!==void 0);if(L.length>0){let Y=M.createDiv({cls:"af-form-row af-form-row-toggle"}),Te=Y.createDiv({cls:"af-form-label",text:"Wiki access"});this.addTooltip(Te,"Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).");let Le=Y.createDiv({cls:"af-form-field-wrap"});for(let qe of L){let at=Le.createEl("label",{cls:"af-form-checkbox-row"}),We=at.createEl("input",{attr:{type:"checkbox"}});d.wikiReferences.includes(qe.name)&&(We.checked=!0),at.createSpan({text:` ${qe.name}`,cls:"af-form-checkbox-label"}),We.addEventListener("change",()=>{We.checked?d.wikiReferences.includes(qe.name)||d.wikiReferences.push(qe.name):d.wikiReferences=d.wikiReferences.filter(ze=>ze!==qe.name)})}}}if(a.isFolder){let L=u.createDiv({cls:"af-create-section"}),Y=L.createDiv({cls:"af-create-section-header"}),Te=Y.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Te,"heart-pulse");let Le=Y.createSpan({text:"Heartbeat"});this.addTooltip(Le,"Autonomous periodic run \u2014 what the agent does when no one is asking");let qe=L.createDiv({cls:"af-form-row af-form-row-toggle"});qe.createDiv({cls:"af-form-label",text:"Enabled"});let at=qe.createDiv({cls:`af-agent-card-toggle${d.heartbeatEnabled?" on":""}`}),We=L.createDiv();We.setCssStyles({display:d.heartbeatEnabled?"":"none"}),at.onclick=()=>{let ct=at.hasClass("on");at.toggleClass("on",!ct),d.heartbeatEnabled=!ct,We.setCssStyles({display:ct?"none":""})},this.renderHeartbeatSchedule(We,d);let ze=We.createDiv({cls:"af-form-row af-form-row-toggle"}),Za=ze.createDiv({cls:"af-form-label"});Za.setText("Notify"),this.addTooltip(Za,"Show an Obsidian notice when the heartbeat completes");let Gn=ze.createDiv({cls:`af-agent-card-toggle${d.heartbeatNotify?" on":""}`});Gn.onclick=()=>{let ct=Gn.hasClass("on");Gn.toggleClass("on",!ct),d.heartbeatNotify=!ct};let nl=this.plugin.runtime.getSnapshot(),er=We.createDiv({cls:"af-form-row"}),tr=er.createDiv({cls:"af-form-label"});tr.setText("Post to channel"),this.addTooltip(tr,"Heartbeat results are posted to this Slack channel when the run completes");let Bs=er.createEl("select",{cls:"af-form-select"});Bs.createEl("option",{text:"(none)",attr:{value:""}});for(let ct of nl.channels){let al=Bs.createEl("option",{text:ct.name,attr:{value:ct.name}});ct.name===d.heartbeatChannel&&(al.selected=!0)}Bs.addEventListener("change",()=>{d.heartbeatChannel=Bs.value,nr()});let Vn=We.createDiv({cls:"af-form-row"}),sr=Vn.createDiv({cls:"af-form-label"});sr.setText("Target ID"),this.addTooltip(sr,"Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel\u2019s first allowed user.");let Yn=Vn.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Channel/chat id \u2014 empty = DM"}});Yn.value=d.heartbeatChannelTarget,Yn.addEventListener("input",()=>{d.heartbeatChannelTarget=Yn.value.trim()});let nr=()=>{Vn.setCssStyles({display:d.heartbeatChannel?"":"none"})};nr();let Us=We.createDiv({cls:"af-form-label"});Us.setCssStyles({width:"auto"}),Us.setCssStyles({marginTop:"12px"}),Us.setText("Instruction"),this.addTooltip(Us,'What the agent does on each heartbeat. Also used by the "Run Now" button.');let Kn=We.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Check status, scan for issues, report findings...",rows:"8"}});Kn.value=d.heartbeatBody,Kn.addEventListener("input",()=>{d.heartbeatBody=Kn.value})}let ke=u.createDiv({cls:"af-create-section"}),Oe=ke.createDiv({cls:"af-create-section-header"}),Ue=Oe.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Ue,"puzzle"),Oe.createSpan({text:"Skills"});let Ne=this.plugin.runtime.getSnapshot();if(Ne.skills.length>0){ke.createDiv({cls:"af-form-sublabel",text:"Shared Skills"});let L=ke.createDiv({cls:"af-create-skills-grid"});for(let Y of Ne.skills){let Te=L.createDiv({cls:"af-create-skill-item"}),Le=Te.createEl("input",{cls:"af-form-toggle",attr:{type:"checkbox"}});Le.checked=d.selectedSkills.has(Y.name),Le.addEventListener("change",()=>{Le.checked?d.selectedSkills.add(Y.name):d.selectedSkills.delete(Y.name)});let qe=Te.createDiv({cls:"af-create-skill-label"});qe.createSpan({cls:"af-create-skill-name",text:Y.name}),Y.description&&qe.createSpan({cls:"af-create-skill-desc",text:` \u2014 ${Y.description}`})}}let q=ke.createDiv({cls:"af-form-sublabel"});q.setText("Agent-specific skills"),this.addTooltip(q,"Custom skills/instructions only for this agent, not shared with others");let re=ke.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Custom skills/instructions for this agent...",rows:"4"}});re.value=a.skillsBody,re.addEventListener("input",()=>{d.skillsBody=re.value});let j=u.createDiv({cls:"af-create-section"}),Ce=j.createDiv({cls:"af-create-section-header"}),xe=Ce.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(xe,"plug");let he=Ce.createSpan({text:"MCP Servers"});this.addTooltip(he,"Grant agent access to MCP servers"),this.renderAgentMcpPicker(j,d.selectedMcpServers);let $e=u.createDiv({cls:"af-create-section"}),ie=$e.createDiv({cls:"af-create-section-header"}),I=ie.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(I,"file-text");let se=ie.createSpan({text:"Context"});this.addTooltip(se,"Project-specific context included in every run");let ce=$e.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:"Background info, repo structure, conventions...",rows:"4"}});ce.value=a.contextBody,ce.addEventListener("input",()=>{d.contextBody=ce.value});let te=u.createDiv({cls:"af-create-section"}),st=te.createDiv({cls:"af-create-section-header"}),ls=st.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(ls,"shield-check"),st.createSpan({text:"Permissions"}),this.createFormField(te,"Approval required","git_push, file_delete","Comma-separated tool names",L=>{d.approvalRequired=L},a.approvalRequired.join(", "));let cs=te.createDiv({cls:"af-form-row"});cs.createDiv({cls:"af-form-label",text:"Allowed Commands"});let ds=cs.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(curl *) -Bash(python3 *) -Read -Edit -Write`,rows:"4"}});ds.value=a.permissionRules.allow.join(` -`),ds.addEventListener("input",()=>{d.allowedCommands=ds.value});let Bt=te.createDiv({cls:"af-form-row"});Bt.createDiv({cls:"af-form-label",text:"Blocked Commands"});let B=Bt.createEl("textarea",{cls:"af-create-textarea",attr:{placeholder:`Bash(git push *) -Bash(rm -rf *) -Bash(sudo *)`,rows:"4"}});B.value=a.permissionRules.deny.join(` -`),B.addEventListener("input",()=>{d.blockedCommands=B.value}),te.createDiv({cls:"af-form-hint",text:"On Codex agents these become execpolicy command rules \u2014 only Bash(cmd args *) prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are ignored, and file/network access is governed by Permission Mode (the sandbox)."});let V=te.createDiv({cls:"af-form-row"});V.createDiv({cls:"af-form-label",text:"Memory enabled"});let Ee=V.createDiv({cls:`af-agent-card-toggle${a.memory?" on":""}`});Ee.onclick=()=>{let L=Ee.hasClass("on");Ee.toggleClass("on",!L),d.memory=!L};let we=te.createDiv({cls:"af-form-row"});we.createDiv({cls:"af-form-label",text:"Memory token budget"});let Ae=we.createEl("input",{cls:"af-create-input",attr:{type:"number",min:"200",step:"100"}});Ae.value=String(d.memoryTokenBudget),Ae.addEventListener("input",()=>{let L=parseInt(Ae.value,10);Number.isFinite(L)&&(d.memoryTokenBudget=L)});let nt=te.createDiv({cls:"af-form-row"});nt.createDiv({cls:"af-form-label",text:"Nightly reflection"});let je=nt.createDiv({cls:`af-agent-card-toggle${a.reflection.enabled?" on":""}`});je.onclick=()=>{let L=je.hasClass("on");je.toggleClass("on",!L),d.reflectionEnabled=!L};let Tt=te.createDiv({cls:"af-form-row"});Tt.createDiv({cls:"af-form-label",text:"Propose skills from memory"});let Ut=Tt.createDiv({cls:`af-agent-card-toggle${a.reflection.proposeSkills?" on":""}`});Ut.onclick=()=>{let L=Ut.hasClass("on");Ut.toggleClass("on",!L),d.reflectionProposeSkills=!L};let bt=s.createDiv({cls:"af-create-footer"}),hs=bt.createEl("button",{cls:"af-btn-sm danger"});R(hs,"trash-2","af-btn-icon"),hs.appendText(" Delete"),hs.onclick=()=>void this.plugin.deleteAgent(a.name),bt.createDiv({cls:"af-toolbar-spacer"});let Ns=bt.createEl("button",{cls:"af-btn-sm",text:"Cancel"});Ns.onclick=()=>this.navigate("agent-detail",a.name);let $t=bt.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R($t,"check","af-btn-icon"),$t.appendText(" Save Changes"),$t.onclick=async()=>{let L=Y=>Y.split(",").map(Te=>Te.trim()).filter(Boolean);try{let Y=Te=>ye(Te).map(Le=>Le.trim()).filter(Boolean);await this.plugin.repository.updateAgent(a.name,{description:d.description.trim(),avatar:d.avatar.trim(),tags:L(d.tags),systemPrompt:d.systemPrompt.trim(),model:d.model.trim()||"default",adapter:d.adapter,cwd:d.cwd.trim(),timeout:d.timeout,permissionMode:d.permissionMode,effort:d.effort||void 0,approvalRequired:L(d.approvalRequired),memory:d.memory,memoryTokenBudget:d.memoryTokenBudget,reflectionEnabled:d.reflectionEnabled,reflectionProposeSkills:d.reflectionProposeSkills,skills:Array.from(d.selectedSkills),mcpServers:Array.from(d.selectedMcpServers),skillsBody:d.skillsBody.trim(),contextBody:d.contextBody.trim(),enabled:d.enabled,permissionRules:{allow:Y(d.allowedCommands),deny:Y(d.blockedCommands)},autoCompactThreshold:d.autoCompactThreshold,wikiReferences:d.wikiReferences}),a.isFolder&&await this.plugin.repository.updateHeartbeat(a.name,{enabled:d.heartbeatEnabled,schedule:d.heartbeatSchedule.trim(),notify:d.heartbeatNotify,channel:d.heartbeatChannel,channelTarget:d.heartbeatChannel?d.heartbeatChannelTarget:"",body:d.heartbeatBody.trim()}),new b.Notice(`Agent "${a.name}" updated.`),await this.plugin.refreshFromVault(),this.navigate("agent-detail",a.name)}catch(Y){let Te=Y instanceof Error?Y.message:String(Y);new b.Notice(`Failed to update agent: ${Te}`)}}}renderTaskChannelDelivery(e,s,n){let a=e.createDiv({cls:"af-form-row"}),i=a.createDiv({cls:"af-form-label",text:"Channel"}),o=a.createEl("select",{cls:"af-form-select"});o.createEl("option",{text:"\u2014 none (run log only) \u2014",attr:{value:""}});for(let u of s){let p=o.createEl("option",{text:u.name,attr:{value:u.name}});u.name===n.channel&&(p.selected=!0)}this.addTooltip(i,"Post this task\u2019s full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).");let c=e.createDiv({cls:"af-form-row"}),l=c.createDiv({cls:"af-form-label",text:"Target ID"}),h=c.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:"Discord/Slack channel ID or Telegram chat ID \u2014 empty = DM you"}});h.value=n.channelTarget,h.addEventListener("input",()=>{n.channelTarget=h.value.trim()}),this.addTooltip(l,"Where in the channel to post. For Discord, enable Developer Mode and right-click the channel \u2192 Copy Channel ID. Leave empty to DM the first allowed user instead.");let d=()=>{c.setCssStyles({display:n.channel?"":"none"})};d(),o.addEventListener("change",()=>{n.channel=o.value,d()})}renderCreateTaskPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=this.plugin.runtime.getSnapshot(),a=s.createDiv({cls:"af-detail-header"}),i=a.createDiv({cls:"af-detail-header-left"}),o=i.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(o,"plus");let c=i.createDiv();c.createDiv({cls:"af-detail-header-name",text:"Create New Task"}),c.createDiv({cls:"af-detail-header-desc",text:"Configure a new task for your fleet"});let l=a.createDiv({cls:"af-detail-header-actions"}),h={title:"",agent:n.agents[0]?.name??"",priority:"medium",tags:"",body:"",scheduleEnabled:!1,scheduleMode:"recurring",schedule:"0 9 * * *",runAt:"",type:"immediate",enabled:!0,catchUp:!0,effort:"",model:"",channel:"",channelTarget:""},d=s.createDiv({cls:"af-create-form"}),u=d.createDiv({cls:"af-create-section"}),p=u.createDiv({cls:"af-create-section-header"}),m=p.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(m,"file-text"),p.createSpan({text:"Task Details"}),this.createFormField(u,"Title","Daily status report","Used as the task identifier",q=>{h.title=q});let f=u.createDiv({cls:"af-form-row"});f.createDiv({cls:"af-form-label",text:"Agent"});let g=f.createEl("select",{cls:"af-form-select"});for(let q of n.agents)g.createEl("option",{text:q.name,attr:{value:q.name}});g.addEventListener("change",()=>{h.agent=g.value});let y=u.createDiv({cls:"af-form-row"});y.createDiv({cls:"af-form-label",text:"Priority"});let v=y.createEl("select",{cls:"af-form-select"}),k=[["low","Low"],["medium","Medium"],["high","High"],["critical","Critical"]];for(let[q,re]of k){let j=v.createEl("option",{text:re,attr:{value:q}});q==="medium"&&(j.selected=!0)}v.addEventListener("change",()=>{h.priority=v.value}),this.createFormField(u,"Tags","monitoring, devops","Comma-separated",q=>{h.tags=q});let w=d.createDiv({cls:"af-create-section"}),S=w.createDiv({cls:"af-create-section-header"}),T=S.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(T,"message-square"),S.createSpan({text:"Instructions"});let _=w.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Describe what the agent should do...",rows:"10"}});_.addEventListener("input",()=>{h.body=_.value});let D=d.createDiv({cls:"af-create-section"}),O=D.createDiv({cls:"af-create-section-header"}),C=O.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(C,"clock"),O.createSpan({text:"Schedule"});let E=D.createDiv({cls:"af-form-row af-form-row-toggle"});E.createDiv({cls:"af-form-label",text:"Enable schedule"});let P=E.createDiv({cls:"af-agent-card-toggle"}),N=D.createDiv({cls:"af-schedule-body"});N.setCssStyles({display:"none"}),P.onclick=()=>{let q=P.hasClass("on");P.toggleClass("on",!q),h.scheduleEnabled=!q,N.setCssStyles({display:q?"none":""}),q?h.type="immediate":h.type=h.scheduleMode==="once"?"once":"recurring"};let M=N.createDiv({cls:"af-form-row"});M.createDiv({cls:"af-form-label",text:"Mode"});let U=M.createEl("select",{cls:"af-form-select"});for(let[q,re]of[["recurring","Recurring"],["once","One-time"]])U.createEl("option",{text:re,attr:{value:q}});let $=N.createDiv(),Z=N.createDiv();Z.setCssStyles({display:"none"}),this.renderInlineSchedule($,h);let de=Z.createDiv({cls:"af-form-row"});de.createDiv({cls:"af-form-label",text:"Run at"});let me=de.createEl("input",{cls:"af-form-input",attr:{type:"datetime-local",value:this.toDatetimeLocal(new Date(Date.now()+36e5))}});h.runAt=new Date(me.value).toISOString(),me.addEventListener("input",()=>{h.runAt=me.value?new Date(me.value).toISOString():""}),U.addEventListener("change",()=>{h.scheduleMode=U.value,$.setCssStyles({display:h.scheduleMode==="recurring"?"":"none"}),Z.setCssStyles({display:h.scheduleMode==="once"?"":"none"}),h.scheduleEnabled&&(h.type=h.scheduleMode==="once"?"once":"recurring")});let ge=N.createDiv({cls:"af-form-row af-form-row-toggle"});ge.createDiv({cls:"af-form-label",text:"Enabled"});let X=ge.createDiv({cls:"af-agent-card-toggle on"});X.onclick=()=>{let q=X.hasClass("on");X.toggleClass("on",!q),h.enabled=!q};let W=N.createDiv({cls:"af-form-row af-form-row-toggle"});W.createDiv({cls:"af-form-label"}).setText("Catch up if missed");let ne=W.createDiv({cls:`af-agent-card-toggle${h.catchUp?" on":""}`});ne.onclick=()=>{let q=ne.hasClass("on");ne.toggleClass("on",!q),h.catchUp=!q};let G=d.createDiv({cls:"af-create-section"}),ee=G.createDiv({cls:"af-create-section-header"}),Q=ee.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Q,"gauge"),ee.createSpan({text:"Execution"});let ae=G.createDiv({cls:"af-form-row"}),ve=ae.createDiv({cls:"af-form-label",text:"Model"}),Pe=ae.createDiv({cls:"af-form-field-wrap"}),Ie=q=>{Pe.empty();let re=n.agents.find(j=>j.name===q);Rt(Pe,{value:h.model,adapter:re?.adapter,onChange:j=>{h.model=j},allowInherit:!0,inheritPlaceholder:re?`Inherit from ${re.name}${re.model?` (${re.model})`:""}`:"Inherit from agent"})};Ie(h.agent),g.addEventListener("change",()=>Ie(g.value)),this.addTooltip(ve,"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.");let Me=G.createDiv({cls:"af-form-row"}),Fe=Me.createDiv({cls:"af-form-label",text:"Effort"}),ke=Me.createEl("select",{cls:"af-form-select"});for(let[q,re]of[["","Agent Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]]){let j=ke.createEl("option",{text:re,attr:{value:q}});q===h.effort&&(j.selected=!0)}ke.addEventListener("change",()=>{h.effort=ke.value}),this.renderTaskChannelDelivery(G,n.channels,h),this.addTooltip(Fe,"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.");let Oe=s.createDiv({cls:"af-create-footer"}),Ue=Oe.createEl("button",{cls:"af-btn-sm",text:"Cancel"});Ue.onclick=()=>this.navigate("kanban");let Ne=Oe.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(Ne,"plus","af-btn-icon"),Ne.appendText(" Create Task"),Ne.onclick=async()=>{let q=h.title.trim();if(!q){new b.Notice("Task title is required.");return}let re=oe(q),j=he=>he.split(",").map($e=>$e.trim()).filter(Boolean),Ce=h.scheduleEnabled?h.scheduleMode==="once"?"once":"recurring":"immediate",xe={task_id:re,agent:h.agent,type:Ce,priority:h.priority,enabled:h.enabled,created:this.toLocalISO(new Date),run_count:0,catch_up:h.catchUp,effort:h.effort||void 0,model:h.model||void 0,channel:h.channel||void 0,channel_target:h.channel&&h.channelTarget?h.channelTarget:void 0,tags:j(h.tags)};if(Ce==="recurring")xe.schedule=h.schedule.trim()||"0 9 * * *";else if(Ce==="once"){if(!h.runAt){new b.Notice("Pick a date/time for the one-time run.");return}xe.run_at=h.runAt}try{let he=await this.plugin.repository.getAvailablePath(this.plugin.repository.getSubfolder("tasks"),re);await this.plugin.app.vault.create(he,H(xe,h.body.trim()||"Describe the task here.")),new b.Notice(`Task "${re}" created.`),await this.plugin.refreshFromVault(),this.navigate("task-detail",re)}catch(he){let $e=he instanceof Error?he.message:String(he);new b.Notice(`Failed to create task: ${$e}`)}}}toLocalISO(e){let s=n=>String(n).padStart(2,"0");return`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}T${s(e.getHours())}:${s(e.getMinutes())}:${s(e.getSeconds())}`}toDatetimeLocal(e){let s=n=>String(n).padStart(2,"0");return`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())}T${s(e.getHours())}:${s(e.getMinutes())}`}renderEditTaskPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"circle-dot","No task selected","");return}let a=this.plugin.runtime.getSnapshot().tasks.find(I=>I.taskId===n);if(!a){this.renderEmptyState(s,"circle-dot","Task not found",`Task "${n}" was not found`);return}let i=this.plugin.runtime.getSnapshot(),o=s.createDiv({cls:"af-detail-header"}),c=o.createDiv({cls:"af-detail-header-left"}),l=c.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(l,"edit");let h=c.createDiv();h.createDiv({cls:"af-detail-header-name",text:`Edit Task: ${a.taskId}`}),h.createDiv({cls:"af-detail-header-desc",text:"Modify task configuration"});let d=o.createDiv({cls:"af-detail-header-actions"}),u=!!(a.schedule||a.runAt),p={agent:a.agent,type:a.type,priority:a.priority,schedule:a.schedule??"0 9 * * *",runAt:a.runAt??"",scheduleEnabled:u,scheduleMode:a.type==="once"?"once":"recurring",enabled:a.enabled,catchUp:a.catchUp,effort:a.effort??"",model:a.model??"",channel:a.channel??"",channelTarget:a.channelTarget??"",tags:a.tags.join(", "),body:a.body},m=s.createDiv({cls:"af-create-form"}),f=m.createDiv({cls:"af-create-section"}),g=f.createDiv({cls:"af-create-section-header"}),y=g.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(y,"file-text"),g.createSpan({text:"Task Details"});let v=f.createDiv({cls:"af-form-row"});v.createDiv({cls:"af-form-label",text:"Title"}),v.createEl("input",{cls:"af-form-input",attr:{type:"text",value:a.taskId,disabled:"true"}}).setCssStyles({opacity:"0.6"});let w=f.createDiv({cls:"af-form-row"});w.createDiv({cls:"af-form-label",text:"Agent"});let S=w.createEl("select",{cls:"af-form-select"});for(let I of i.agents){let se=S.createEl("option",{text:I.name,attr:{value:I.name}});I.name===a.agent&&(se.selected=!0)}S.addEventListener("change",()=>{p.agent=S.value});let T=f.createDiv({cls:"af-form-row"});T.createDiv({cls:"af-form-label",text:"Priority"});let _=T.createEl("select",{cls:"af-form-select"}),D=[["low","Low"],["medium","Medium"],["high","High"],["critical","Critical"]];for(let[I,se]of D){let ce=_.createEl("option",{text:se,attr:{value:I}});I===a.priority&&(ce.selected=!0)}_.addEventListener("change",()=>{p.priority=_.value}),this.createFormField(f,"Tags","monitoring, critical","Comma-separated",I=>{p.tags=I},a.tags.join(", "));let O=m.createDiv({cls:"af-create-section"}),C=O.createDiv({cls:"af-create-section-header"}),E=C.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(E,"message-square"),C.createSpan({text:"Instructions"});let P=O.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Describe what the agent should do...",rows:"10"}});P.value=a.body,P.addEventListener("input",()=>{p.body=P.value});let N=m.createDiv({cls:"af-create-section"}),M=N.createDiv({cls:"af-create-section-header"}),U=M.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(U,"clock"),M.createSpan({text:"Schedule"});let $=N.createDiv({cls:"af-form-row af-form-row-toggle"});$.createDiv({cls:"af-form-label",text:"Enable schedule"});let Z=$.createDiv({cls:`af-agent-card-toggle${u?" on":""}`}),de=N.createDiv({cls:"af-schedule-body"});de.setCssStyles({display:u?"":"none"}),Z.onclick=()=>{let I=Z.hasClass("on");Z.toggleClass("on",!I),p.scheduleEnabled=!I,de.setCssStyles({display:I?"none":""}),I?p.type="immediate":p.type=p.scheduleMode==="once"?"once":"recurring"};let me=de.createDiv({cls:"af-form-row"});me.createDiv({cls:"af-form-label",text:"Mode"});let ge=me.createEl("select",{cls:"af-form-select"});for(let[I,se]of[["recurring","Recurring"],["once","One-time"]]){let ce=ge.createEl("option",{text:se,attr:{value:I}});I===p.scheduleMode&&(ce.selected=!0)}let X=de.createDiv(),W=de.createDiv();X.setCssStyles({display:p.scheduleMode==="recurring"?"":"none"}),W.setCssStyles({display:p.scheduleMode==="once"?"":"none"}),this.renderInlineSchedule(X,p);let K=W.createDiv({cls:"af-form-row"});K.createDiv({cls:"af-form-label",text:"Run at"});let ne=p.runAt?this.toDatetimeLocal(new Date(p.runAt)):this.toDatetimeLocal(new Date(Date.now()+36e5)),G=K.createEl("input",{cls:"af-form-input",attr:{type:"datetime-local",value:ne}});p.runAt||(p.runAt=new Date(G.value).toISOString()),G.addEventListener("input",()=>{p.runAt=G.value?new Date(G.value).toISOString():""}),ge.addEventListener("change",()=>{p.scheduleMode=ge.value,X.setCssStyles({display:p.scheduleMode==="recurring"?"":"none"}),W.setCssStyles({display:p.scheduleMode==="once"?"":"none"}),p.scheduleEnabled&&(p.type=p.scheduleMode==="once"?"once":"recurring")});let ee=de.createDiv({cls:"af-form-row af-form-row-toggle"});ee.createDiv({cls:"af-form-label",text:"Enabled"});let Q=ee.createDiv({cls:`af-agent-card-toggle${a.enabled?" on":""}`});Q.onclick=()=>{let I=Q.hasClass("on");Q.toggleClass("on",!I),p.enabled=!I};let ae=de.createDiv({cls:"af-form-row af-form-row-toggle"});ae.createDiv({cls:"af-form-label"}).setText("Catch up if missed");let Pe=ae.createDiv({cls:`af-agent-card-toggle${p.catchUp?" on":""}`});Pe.onclick=()=>{let I=Pe.hasClass("on");Pe.toggleClass("on",!I),p.catchUp=!I};let Ie=m.createDiv({cls:"af-create-section"}),Me=Ie.createDiv({cls:"af-create-section-header"}),Fe=Me.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(Fe,"gauge"),Me.createSpan({text:"Execution"});let ke=Ie.createDiv({cls:"af-form-row"}),Oe=ke.createDiv({cls:"af-form-label",text:"Effort"}),Ue=ke.createEl("select",{cls:"af-form-select"}),Ne=[["","Agent Default"],["low","Low"],["medium","Medium"],["high","High"],["max","Max"]];for(let[I,se]of Ne){let ce=Ue.createEl("option",{text:se,attr:{value:I}});I===p.effort&&(ce.selected=!0)}Ue.addEventListener("change",()=>{p.effort=Ue.value}),this.addTooltip(Oe,"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.");let q=Ie.createDiv({cls:"af-form-row"}),re=q.createDiv({cls:"af-form-label",text:"Model"}),j=q.createDiv({cls:"af-form-field-wrap"}),Ce=I=>{j.empty();let se=i.agents.find(ce=>ce.name===I);Rt(j,{value:p.model,adapter:se?.adapter,onChange:ce=>{p.model=ce},allowInherit:!0,inheritPlaceholder:se?`Inherit from ${se.name}${se.model?` (${se.model})`:""}`:"Inherit from agent"})};Ce(p.agent),S.addEventListener("change",()=>Ce(S.value)),this.renderTaskChannelDelivery(Ie,i.channels,p),this.addTooltip(re,"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.");let xe=s.createDiv({cls:"af-create-footer"}),he=xe.createEl("button",{cls:"af-btn-sm danger"});R(he,"trash-2","af-btn-icon"),he.appendText(" Delete"),he.onclick=async()=>{await this.plugin.repository.deleteTask(a.taskId),new b.Notice(`Task "${a.taskId}" deleted.`),await new Promise(I=>window.setTimeout(I,200)),await this.plugin.refreshFromVault(),this.navigate("kanban")},xe.createDiv({cls:"af-toolbar-spacer"});let $e=xe.createEl("button",{cls:"af-btn-sm",text:"Cancel"});$e.onclick=()=>this.navigate("task-detail",a.taskId);let ie=xe.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(ie,"check","af-btn-icon"),ie.appendText(" Save Changes"),ie.onclick=async()=>{let I=ce=>ce.split(",").map(te=>te.trim()).filter(Boolean),se=p.scheduleEnabled?p.scheduleMode==="once"?"once":"recurring":"immediate";if(se==="once"&&!p.runAt){new b.Notice("Pick a date/time for the one-time run.");return}try{await this.plugin.repository.updateTask(a.taskId,{agent:p.agent,type:se,priority:p.priority,schedule:se==="recurring"?p.schedule.trim():"",runAt:se==="once"?p.runAt:"",enabled:p.enabled,catch_up:p.catchUp,effort:p.effort||void 0,model:p.model||"",channel:p.channel||"",channelTarget:p.channel&&p.channelTarget?p.channelTarget:"",tags:I(p.tags),body:p.body.trim()}),new b.Notice(`Task "${a.taskId}" updated.`),await this.plugin.refreshFromVault(),this.navigate("task-detail",a.taskId)}catch(ce){let te=ce instanceof Error?ce.message:String(ce);new b.Notice(`Failed to update task: ${te}`)}}}renderEditSkillPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"puzzle","No skill selected","");return}let a=this.plugin.runtime.getSnapshot().skills.find(G=>G.name===n);if(!a){this.renderEmptyState(s,"puzzle","Skill not found",`Skill "${n}" was not found`);return}let i=s.createDiv({cls:"af-detail-header"}),o=i.createDiv({cls:"af-detail-header-left"}),c=o.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(c,"edit");let l=o.createDiv();l.createDiv({cls:"af-detail-header-name",text:`Edit Skill: ${a.name}`}),l.createDiv({cls:"af-detail-header-desc",text:"Modify skill definition"});let h=i.createDiv({cls:"af-detail-header-actions"}),d={description:a.description??"",tags:a.tags.join(", "),body:a.body,toolsBody:a.toolsBody,referencesBody:a.referencesBody,examplesBody:a.examplesBody},u=s.createDiv({cls:"af-create-form"}),p=u.createDiv({cls:"af-create-section"}),m=p.createDiv({cls:"af-create-section-header"}),f=m.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(f,"puzzle"),m.createSpan({text:"Identity"});let g=p.createDiv({cls:"af-form-row"});g.createDiv({cls:"af-form-label",text:"Name"}),g.createEl("input",{cls:"af-form-input",attr:{type:"text",value:a.name,disabled:"true"}}).setCssStyles({opacity:"0.6"}),this.createFormField(p,"Description","Manage tasks and projects via CLI","",G=>{d.description=G},a.description??""),this.createFormField(p,"Tags","productivity, tasks","Comma-separated",G=>{d.tags=G},a.tags.join(", "));let v=u.createDiv({cls:"af-create-section"}),k=v.createDiv({cls:"af-create-section-header"}),w=k.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(w,"file-text"),k.createSpan({text:"Core Instructions"});let S=v.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"Skill instructions...",rows:"10"}});S.value=a.body,S.addEventListener("input",()=>{d.body=S.value});let T=u.createDiv({cls:"af-create-section"}),_=T.createDiv({cls:"af-create-section-header"}),D=_.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(D,"wrench");let O=_.createSpan({text:"Tools"});this.addTooltip(O,"CLI commands, API endpoints, and tool definitions available to agents using this skill");let C=T.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`## Commands +Agent: ...`,rows:"6"}});t&&(oe.value=t.examplesBody),oe.addEventListener("input",()=>{l.examplesBody=oe.value});let be=a.createDiv({cls:"af-create-footer"});if(t){let H=be.createEl("button",{cls:"af-btn-sm danger"});T(H,"trash-2","af-btn-icon"),H.appendText(" Delete"),H.onclick=()=>{new ut(s.app,{title:`Delete skill "${t.name}"?`,body:"The skill file will be moved to your system trash and can be recovered.",confirmText:"Delete",danger:!0,onConfirm:async()=>{await s.repository.deleteSkill(t.name),new Ve.Notice(`Skill "${t.name}" deleted.`),await new Promise(X=>window.setTimeout(X,200)),await s.refreshFromVault(),e.navigate("skills")}}).open()},be.createDiv({cls:"af-toolbar-spacer"})}let ne=be.createEl("button",{cls:"af-btn-sm",text:"Cancel"});ne.onclick=()=>e.navigate("skills");let Y=be.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(Y,t?"check":"plus","af-btn-icon"),Y.appendText(t?" Save Changes":" Create Skill"),t?Y.onclick=async()=>{let H=me=>me.split(",").map(ee=>ee.trim()).filter(Boolean),X=e.markSubmitBusy(Y,"Saving...","check","Save Changes");try{await s.repository.updateSkill(t.name,{description:l.description.trim(),tags:H(l.tags),body:l.body.trim(),toolsBody:l.toolsBody.trim(),referencesBody:l.referencesBody.trim(),examplesBody:l.examplesBody.trim()}),new Ve.Notice(`Skill "${t.name}" updated.`),await s.refreshFromVault(),e.navigate("skills")}catch(me){let ee=me instanceof Error?me.message:String(me);new Ve.Notice(`Failed to update skill: ${ee}`),X()}}:Y.onclick=async()=>{let H=l.name.trim();if(!H){new Ve.Notice("Skill name is required.");return}let X=z(H);if(s.repository.getSkillByName(X)){new Ve.Notice(`Skill "${X}" already exists.`);return}let me=J=>J.split(",").map(K=>K.trim()).filter(Boolean),ee=e.markSubmitBusy(Y,"Creating...","plus","Create Skill");try{await s.repository.createSkillFolder({name:X,description:l.description.trim(),tags:me(l.tags),body:l.body.trim(),toolsBody:l.toolsBody.trim(),referencesBody:l.referencesBody.trim(),examplesBody:l.examplesBody.trim()}),new Ve.Notice(`Skill "${X}" created.`),await s.refreshFromVault(),e.navigate("skills")}catch(J){let K=J instanceof Error?J.message:String(J);new Ve.Notice(`Failed to create skill: ${K}`),ee()}}}var Ye=require("obsidian");function cc(a,e){let{plugin:t}=e,s=a.createDiv({cls:"af-detail-header"}),n=s.createDiv({cls:"af-detail-header-left"}),r=n.createDiv({cls:"af-agent-card-avatar idle"});(0,Ye.setIcon)(r,"plus");let i=n.createDiv();i.createDiv({cls:"af-detail-header-name",text:"Add MCP Server"}),i.createDiv({cls:"af-detail-header-desc",text:"Register a new MCP server for agents to use"}),s.createDiv({cls:"af-detail-header-actions"});let o={name:"",transport:"stdio",description:"",command:"",args:"",envVars:"",url:"",headers:"",auth:"none",bearerToken:""},l=a.createDiv({cls:"af-create-form"}),c=l.createDiv({cls:"af-create-section"}),d=c.createDiv({cls:"af-create-section-header"}),u=d.createSpan({cls:"af-create-section-icon"});(0,Ye.setIcon)(u,"plug"),d.createSpan({text:"Server Details"}),e.createFormField(c,"Name","my-server","Unique name for this MCP server",Y=>{o.name=Y});let h=c.createDiv({cls:"af-form-row"}),p=h.createDiv({cls:"af-form-label"});p.setText("Transport"),e.addTooltip(p,"stdio: local process, http/sse: remote server");let m=h.createEl("select",{cls:"af-form-select"});m.createEl("option",{text:"stdio",attr:{value:"stdio"}}),m.createEl("option",{text:"http",attr:{value:"http"}}),m.createEl("option",{text:"sse",attr:{value:"sse"}}),e.createFormField(c,"Description","What this server does (optional)","Shown on the server card",Y=>{o.description=Y});let f=l.createDiv({cls:"af-create-section"}),g=f.createDiv({cls:"af-create-section-header"}),w=g.createSpan({cls:"af-create-section-icon"});(0,Ye.setIcon)(w,"terminal"),g.createSpan({text:"Process Configuration"}),e.createFormField(f,"Command","npx @anthropic-ai/mcp-server-memory","The command to run",Y=>{o.command=Y}),e.createFormField(f,"Arguments","--port 3000","Space-separated arguments (optional)",Y=>{o.args=Y});let y=f.createDiv({cls:"af-form-label"});y.setText("Environment variables"),e.addTooltip(y,"One KEY=VALUE per line");let v=f.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`API_KEY=sk-... +DEBUG=true`,rows:"3"}});v.addEventListener("input",()=>{o.envVars=v.value});let k=l.createDiv({cls:"af-create-section"}),b=k.createDiv({cls:"af-create-section-header"}),P=b.createSpan({cls:"af-create-section-icon"});(0,Ye.setIcon)(P,"globe"),b.createSpan({text:"Remote Server Configuration"}),e.createFormField(k,"URL","https://mcp.example.com/sse","Server endpoint URL",Y=>{o.url=Y});let A=k.createDiv({cls:"af-form-label"});A.setText("Custom headers"),e.addTooltip(A,"One Header: Value per line (optional, non-secret)");let M=k.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"X-Custom-Header: value",rows:"2"}});M.addEventListener("input",()=>{o.headers=M.value});let E=k.createDiv({cls:"af-form-row"}),x=E.createDiv({cls:"af-form-label"});x.setText("Authentication"),e.addTooltip(x,"none, a static bearer token, or OAuth (authenticate after saving)");let _=E.createEl("select",{cls:"af-form-select"});_.createEl("option",{text:"None",attr:{value:"none"}}),_.createEl("option",{text:"Bearer token",attr:{value:"bearer"}}),_.createEl("option",{text:"OAuth",attr:{value:"oauth"}});let R=k.createDiv({cls:"af-form-row"}),O=R.createDiv({cls:"af-form-label"});O.setText("Bearer token"),e.addTooltip(O,"Stored securely in the OS keychain, never written to the vault");let F=R.createEl("input",{cls:"af-form-input",attr:{type:"password",placeholder:"sk-\u2026"}});F.addEventListener("input",()=>{o.bearerToken=F.value});let B=()=>{R.setCssStyles({display:o.auth==="bearer"?"":"none"})};_.addEventListener("change",()=>{o.auth=_.value,B()}),B();let q=()=>{f.setCssStyles({display:o.transport==="stdio"?"":"none"}),k.setCssStyles({display:o.transport!=="stdio"?"":"none"})};m.addEventListener("change",()=>{o.transport=m.value,q()}),q();let oe=a.createDiv({cls:"af-create-footer"}),be=oe.createEl("button",{cls:"af-btn-sm",text:"Cancel"});be.onclick=()=>e.navigate("mcp");let ne=oe.createEl("button",{cls:"af-btn-sm primary af-create-submit"});T(ne,"plus","af-btn-icon"),ne.appendText(" Add Server"),ne.onclick=async()=>{let Y=o.name.trim();if(!Y){new Ye.Notice("Server name is required.");return}if(o.transport==="stdio"){if(!o.command.trim()){new Ye.Notice("Command is required for stdio servers.");return}}else if(!o.url.trim()){new Ye.Notice("URL is required for HTTP/SSE servers.");return}let H={};if(o.envVars.trim())for(let J of re(o.envVars)){let K=J.trim();if(!K)continue;let ke=K.indexOf("=");if(ke<=0){new Ye.Notice(`Invalid env var: ${K}`);return}H[K.slice(0,ke)]=K.slice(ke+1)}let X={};if(o.headers.trim())for(let J of re(o.headers)){let K=J.trim();if(!K)continue;let ke=K.indexOf(":");if(ke<=0){new Ye.Notice(`Invalid header: ${K}`);return}X[K.slice(0,ke).trim()]=K.slice(ke+1).trim()}let me=o.args.trim()?o.args.trim().split(/\s+/):void 0;if(t.repository.getMcpServerByName(Y)){new Ye.Notice(`An MCP server named "${Y}" already exists.`);return}if(o.transport!=="stdio"&&o.auth==="bearer"&&!o.bearerToken.trim()){new Ye.Notice("Enter a bearer token, or choose a different auth method.");return}ne.disabled=!0,ne.setText("Adding...");let ee={name:Y,type:o.transport,enabled:!0,source:"manual",status:"disconnected",scope:"user",tools:[],toolDetails:[]};o.transport==="stdio"?(ee.command=o.command.trim(),me&&(ee.args=me),Object.keys(H).length>0&&(ee.env=H)):(ee.url=o.url.trim(),Object.keys(X).length>0&&(ee.headers=X),ee.auth=o.auth);try{await t.repository.saveMcpServer(ee,o.description.trim()),o.transport!=="stdio"&&o.auth==="bearer"&&o.bearerToken.trim()&&t.mcpAuth.storeStaticToken(Y,o.bearerToken.trim()),new Ye.Notice(`Server "${Y}" added.`),await t.refreshFromVault(),e.navigate("mcp")}catch(J){let K=J instanceof Error?J.message:String(J);new Ye.Notice(`Failed to add server: ${K}`),ne.disabled=!1,ne.setText(""),T(ne,"plus","af-btn-icon"),ne.appendText(" Add Server")}}}var ln=require("obsidian");function dc(a,e){let t=a.createDiv({cls:"af-agents-page"}),s=e.plugin.runtime.getSnapshot(),n=t.createDiv({cls:"af-agents-toolbar"});n.createDiv({cls:"af-page-title",text:"Agents"}),n.createDiv({cls:"af-toolbar-spacer"});let r=n.createEl("button",{cls:"af-btn-sm primary"});T(r,"plus","af-btn-icon"),r.appendText(" New Agent"),r.onclick=()=>void e.plugin.createAgentTemplate();let i=t.createDiv({cls:"af-agents-grid"});if(s.agents.length===0){e.renderEmptyState(i,"bot","No agents configured","Create your first agent to get started",{label:"New Agent",onClick:()=>void e.plugin.createAgentTemplate()});return}let o=new Map;for(let l of e.plugin.runtime.getRecentRuns()){let c=o.get(l.agent);c?c.push(l):o.set(l.agent,[l])}for(let l of s.agents)ep(i,e,l,s,o.get(l.name)??[])}function ep(a,e,t,s,n){let r=e.plugin.runtime.getAgentState(t.name),i=a.createDiv({cls:`af-agent-card${t.enabled?"":" disabled"}`}),o=i.createDiv({cls:"af-agent-card-header"}),l=t.enabled?e.healthToClass(r.status):"disabled",c=o.createDiv({cls:`af-agent-card-avatar ${l}`});e.renderAgentAvatar(c,t);let d=o.createDiv({cls:"af-agent-card-titleblock"}),u=d.createDiv({cls:"af-agent-card-name"});if(u.appendText(t.name),t.heartbeatEnabled&&t.heartbeatSchedule){let A=u.createSpan({cls:"af-heartbeat-indicator"});(0,ln.setIcon)(A,"heart-pulse"),A.title=`Heartbeat: ${t.heartbeatSchedule}`}d.createDiv({cls:"af-agent-card-desc",text:t.description??"No description"});let h=o.createDiv({cls:`af-agent-card-toggle${t.enabled?" on":""}`});h.onclick=A=>{A.stopPropagation(),e.plugin.toggleAgent(t.name,!t.enabled)};let p=i.createDiv({cls:"af-agent-card-stats"}),m=n.length,f=n.filter(A=>A.status==="success").length,g=m>0?Math.round(f/m*100):0,w=m>0?Math.round(n.reduce((A,M)=>A+M.durationSeconds,0)/m):0,y=n.reduce((A,M)=>A+(M.tokensUsed??0),0);if(e.renderAgentStat(p,String(m),"Runs"),e.renderAgentStat(p,`${g}%`,"Success"),e.renderAgentStat(p,`${w}s`,"Avg Time"),e.renderAgentStat(p,e.formatTokenCount(y),"Tokens"),t.skills.length>0){let A=i.createDiv({cls:"af-agent-card-skills"});for(let M of t.skills)A.createSpan({cls:"af-skill-tag",text:M})}if(t.isFolder&&(t.heartbeatBody.trim()||t.heartbeatEnabled)){let A=i.createDiv({cls:"af-agent-card-heartbeat"}),M=A.createSpan({cls:"af-agent-card-hb-icon"});(0,ln.setIcon)(M,"heart-pulse");let E=[e.cronToHuman(t.heartbeatSchedule)];if(t.heartbeatEnabled){let _=e.plugin.runtime.getNextHeartbeat(t.name);_&&E.push(`next ${e.timeUntil(_)}`)}else E.push("paused");A.createSpan({cls:"af-agent-card-hb-text",text:`Heartbeat \xB7 ${E.join(" \xB7 ")}`});let x=A.createDiv({cls:`af-agent-card-toggle af-agent-card-toggle-sm${t.heartbeatEnabled?" on":""}`});x.title=t.heartbeatEnabled?"Pause heartbeat":"Enable heartbeat",x.onclick=_=>{_.stopPropagation(),(async()=>(await e.plugin.repository.updateHeartbeat(t.name,{enabled:!t.heartbeatEnabled}),await e.plugin.refreshFromVault(),new ln.Notice(`Heartbeat ${t.heartbeatEnabled?"paused":"enabled"} for ${t.name}`)))()}}let v=i.createDiv({cls:"af-agent-card-footer"}),k=[`Model: ${t.model}`];t.approvalRequired.length>0&&k.push(`Approval: ${t.approvalRequired.join(", ")}`),t.memory&&k.push("Memory: on"),t.enabled||k.unshift("Disabled"),v.createSpan({cls:"af-agent-card-meta",text:k.join(" \xB7 ")});let b=v.createDiv({cls:"af-agent-card-actions"});if(!t.enabled){let A=b.createEl("button",{cls:"af-btn-sm",text:"Enable"});A.onclick=M=>{M.stopPropagation(),e.plugin.toggleAgent(t.name,!0)}}let P=b.createEl("button",{cls:"af-btn-sm"});if(T(P,"edit","af-btn-icon"),P.appendText(" Edit"),P.onclick=A=>{A.stopPropagation(),e.navigate("edit-agent",t.name)},t.enabled){let A=b.createEl("button",{cls:"af-btn-sm primary"});T(A,"play","af-btn-icon"),A.appendText(" Run"),A.onclick=M=>{M.stopPropagation(),e.plugin.runAgentPrompt(t.name)}}i.onclick=()=>e.navigate("agent-detail",t.name)}var cn=require("obsidian");function uc(a,e,t){let s=a.createDiv({cls:"af-agent-detail-page"});if(!t){e.renderEmptyState(s,"bot","No agent selected","Select an agent from the list");return}let n=e.plugin.runtime.getSnapshot().agents.find(y=>y.name===t);if(!n){e.renderEmptyState(s,"bot","Agent not found",`Agent "${t}" was not found`);return}let r=e.plugin.runtime.getAgentState(n.name),i=e.plugin.runtime.getRecentRuns().filter(y=>y.agent===n.name),o=s.createDiv({cls:"af-detail-header"}),l=o.createDiv({cls:"af-detail-header-left"}),c=l.createDiv({cls:`af-agent-card-avatar ${e.healthToClass(r.status)}`});e.renderAgentAvatar(c,n);let d=l.createDiv();d.createDiv({cls:"af-detail-header-name",text:n.name}),d.createDiv({cls:"af-detail-header-desc",text:n.description??"No description"});let u=o.createDiv({cls:"af-detail-header-actions"}),h=u.createEl("button",{cls:"af-btn-sm primary"});if(T(h,"message-circle","af-btn-icon"),h.appendText(" Chat"),h.onclick=()=>e.openChatSlideover(n),n.enabled){let y=u.createEl("button",{cls:"af-btn-sm"});T(y,"play","af-btn-icon"),y.appendText(" Run Now"),y.onclick=()=>void e.plugin.runAgentPrompt(n.name);let v=u.createEl("button",{cls:"af-btn-sm"});T(v,"pause","af-btn-icon"),v.appendText(" Disable"),v.onclick=()=>void e.plugin.toggleAgent(n.name,!1)}else{let y=u.createEl("button",{cls:"af-btn-sm"});T(y,"play","af-btn-icon"),y.appendText(" Enable"),y.onclick=()=>void e.plugin.toggleAgent(n.name,!0)}let p=u.createEl("button",{cls:"af-btn-sm"});T(p,"edit","af-btn-icon"),p.appendText(" Edit"),p.onclick=()=>e.navigate("edit-agent",n.name);let m=u.createEl("button",{cls:"af-btn-sm danger"});T(m,"trash-2","af-btn-icon"),m.appendText(" Delete"),m.onclick=()=>void e.plugin.deleteAgent(n.name);let f=s.createDiv({cls:"af-detail-tabs"}),g=[{id:"overview",label:"Overview",icon:"layout-dashboard"},{id:"config",label:"Config",icon:"settings"},{id:"runs",label:"Runs",icon:"scroll-text"},{id:"memory",label:"Memory",icon:"file-text"}];for(let y of g){let v=f.createEl("button",{cls:`af-detail-tab${e.getDetailTab()===y.id?" active":""}`});T(v,y.icon,"af-tab-icon"),v.appendText(` ${y.label}`),v.onclick=()=>{e.setDetailTab(y.id),e.rerender()}}let w=s.createDiv({cls:"af-detail-tab-content"});switch(e.getDetailTab()){case"overview":tp(w,e,n,i);break;case"config":sp(w,e,n);break;case"runs":np(w,e,i);break;case"memory":hc(w,e,n);break}}function tp(a,e,t,s){let n=a.createDiv({cls:"af-dash-grid"}),r=s.length,i=s.filter(v=>v.status==="success").length,o=r>0?Math.round(i/r*100):0,l=r>0?Math.round(s.reduce((v,k)=>v+k.durationSeconds,0)/r):0,c=e.plugin.runtime.getUsageRecords().filter(v=>v.agent===t.name),{tokens:d,cost:u}=e.combinedTotals(s,c),h=u>0?` \xB7 $${u.toFixed(2)}`:"";if(e.renderStatCard(n,"Total Runs",String(r),"","activity","all time"),e.renderStatCard(n,"Success Rate",`${o}%`,"","check-circle-2",`${i}/${r}`),e.renderStatCard(n,"Avg Time",`${l}s`,"","clock","per run"),e.renderStatCard(n,"Total Tokens",e.formatTokenCount(d),"","zap",`all time${h}`),t.isFolder&&(t.heartbeatBody.trim()||t.heartbeatEnabled)){let v=a.createDiv({cls:"af-section-card"}),k=v.createDiv({cls:"af-section-header"}),b=k.createDiv({cls:"af-section-title"});T(b,"heart-pulse"),b.appendText(" Heartbeat");let A=k.createDiv({cls:"af-detail-header-actions"}).createDiv({cls:`af-agent-card-toggle${t.heartbeatEnabled?" on":""}`});A.onclick=async()=>{let x=A.hasClass("on");await e.plugin.repository.updateHeartbeat(t.name,{enabled:!x}),await e.plugin.refreshFromVault(),new cn.Notice(`Heartbeat ${x?"paused":"enabled"} for ${t.name}`)};let M=v.createDiv({cls:"af-config-form"});e.renderConfigRow(M,"Schedule",e.cronToHuman(t.heartbeatSchedule));let E=e.plugin.runtime.getNextHeartbeat(t.name);E&&t.heartbeatEnabled&&e.renderConfigRow(M,"Next run",e.timeUntil(E)),t.heartbeatChannel&&e.renderConfigRow(M,"Channel",t.heartbeatChannel)}if(t.skills.length>0){let v=a.createDiv({cls:"af-section-card"}),b=v.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(b,"puzzle"),b.appendText(" Skills");let P=v.createDiv({cls:"af-detail-skills-list"});for(let A of t.skills)P.createSpan({cls:"af-skill-tag",text:A})}let p=t.mcpServers??[];if(p.length>0){let v=a.createDiv({cls:"af-section-card"}),b=v.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(b,"plug"),b.appendText(" MCP Servers");let P=v.createDiv({cls:"af-mcp-overview-list"}),A=e.plugin.repository.getMcpServers();for(let M of p){let E=A.find(O=>O.name===M),x=P.createDiv({cls:"af-mcp-overview-row"}),_=x.createSpan({cls:`af-mcp-status-dot ${E?E.enabled?E.status:"disabled":"disconnected"}`});_.title=E?E.enabled?E.status:"disabled":"unknown",x.createSpan({cls:"af-mcp-overview-name",text:M});let R=E?.toolDetails.length??E?.tools.length??0;R>0?x.createSpan({cls:"af-mcp-overview-tools",text:`${R} tools`}):E&&!E.enabled?x.createSpan({cls:"af-mcp-overview-tools af-muted",text:"disabled"}):E?.status==="needs-auth"&&x.createSpan({cls:"af-mcp-overview-tools af-muted",text:"needs auth"})}}if(t.permissionRules.allow.length>0||t.permissionRules.deny.length>0||t.permissionMode&&t.permissionMode!=="default"){let v=a.createDiv({cls:"af-section-card"}),b=v.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(b,"shield-check"),b.appendText(" Permissions");let P=v.createDiv({cls:"af-config-form"});e.renderConfigRow(P,"Mode",t.permissionMode||"default"),t.permissionRules.allow.length>0&&e.renderConfigRow(P,"Allowed",t.permissionRules.allow.join(", ")),t.permissionRules.deny.length>0&&e.renderConfigRow(P,"Denied",t.permissionRules.deny.join(", "))}let f=a.createDiv({cls:"af-section-card"}),w=f.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(w,"scroll-text"),w.appendText(" Recent Runs");let y=f.createDiv({cls:"af-timeline"});if(s.length===0)e.renderEmptyState(y,"scroll-text","No runs yet","");else for(let v of s.slice(0,5))e.renderTimelineItem(y,v)}function sp(a,e,t){let s=a.createDiv({cls:"af-config-form"});e.renderConfigRow(s,"Name",t.name),e.renderConfigRow(s,"Description",t.description??""),e.renderConfigRow(s,"Model",t.model),e.renderConfigRow(s,"Timeout",`${t.timeout}s`),e.renderConfigRow(s,"Working Directory",t.cwd??"(vault root)"),e.renderConfigRow(s,"Permission Mode",t.permissionMode||"default"),e.renderConfigRow(s,"Approval Required",t.approvalRequired.join(", ")||"none"),t.permissionRules.allow.length>0&&e.renderConfigRow(s,"Allowed Commands",t.permissionRules.allow.join(", ")),t.permissionRules.deny.length>0&&e.renderConfigRow(s,"Blocked Commands",t.permissionRules.deny.join(", ")),e.renderConfigRow(s,"Memory",t.memory?"Enabled":"Disabled"),e.renderConfigRow(s,"Auto-compact",t.autoCompactThreshold&&t.autoCompactThreshold>0?`at ${t.autoCompactThreshold}% context`:"disabled"),t.wikiReferences&&t.wikiReferences.length>0&&e.renderConfigRow(s,"Wiki access",t.wikiReferences.map(o=>o.agent).join(", ")),e.renderConfigRow(s,"Tags",t.tags.join(", ")||"none");let n=s.createDiv({cls:"af-config-prompt-section"});if(n.createDiv({cls:"af-slideover-section-title",text:"SYSTEM PROMPT"}),n.createDiv({cls:"af-output-block",text:t.body||"(empty)"}),t.heartbeatBody.trim()){let o=s.createDiv({cls:"af-config-prompt-section"});o.createDiv({cls:"af-slideover-section-title",text:"HEARTBEAT INSTRUCTION"}),o.createDiv({cls:"af-output-block",text:t.heartbeatBody})}let i=s.createDiv({cls:"af-slideover-actions"}).createEl("button",{cls:"af-btn-sm primary"});T(i,"edit","af-btn-icon"),i.appendText(" Edit Agent"),i.onclick=()=>e.navigate("edit-agent",t.name)}function np(a,e,t){if(t.length===0){e.renderEmptyState(a,"scroll-text","No runs yet","Run this agent to see history");return}for(let s of t){let n=a.createDiv({cls:"af-run-list-item"}),r=n.createDiv({cls:`af-tl-icon ${e.statusToTimelineClass(s.status)}`});(0,cn.setIcon)(r,e.statusToIconName(s.status));let i=n.createDiv({cls:"af-tl-body"}),o=i.createDiv({cls:"af-tl-title"});o.createSpan({text:s.task}),o.createSpan({cls:`af-status-badge ${e.statusToBadgeClass(s.status)}`,text:e.statusToBadgeText(s.status)});let l=i.createDiv({cls:"af-tl-meta"});l.createSpan({text:`${e.formatStarted(s.started)} \xB7 ${e.formatDuration(s.durationSeconds)}`}),s.tokensUsed&&l.createSpan({text:`${s.tokensUsed.toLocaleString()} tokens`}),i.createDiv({cls:"af-tl-desc",text:At(s.output,120)}),n.onclick=()=>e.openSlideover(s)}}async function hc(a,e,t){if(!t.memory){e.renderEmptyState(a,"file-text","Memory disabled","Enable memory in agent config");return}let s=await e.plugin.repository.readWorkingMemory(t.name),n=a.createDiv({cls:"af-form-help"}),r=s?.tokenEstimate??0,i=t.reflection.enabled?`reflection on (${t.reflection.schedule})`:"reflection off";n.setText(`~${r} / ${t.memoryTokenBudget} tokens \xB7 ${i}`),!s||s.sections.length===0?e.renderEmptyState(a,"file-text","No memories yet","Agent will learn from runs"):a.createDiv({cls:"af-output-block"}).setText(at(s.sections));let o=a.createDiv({cls:"af-slideover-actions"}),l=o.createEl("button",{cls:"af-btn-sm"});T(l,"moon","af-btn-icon"),l.appendText(" Reflect now"),l.onclick=async()=>{l.disabled=!0,l.setText(" Reflecting\u2026");let d=await e.plugin.runtime.runReflectionNow(t.name);new cn.Notice(`Agent Fleet: ${d.message}`),await hc((a.empty(),a),e,t)};let c=o.createEl("button",{cls:"af-btn-sm"});T(c,"external-link","af-btn-icon"),c.appendText(" Open in Editor"),c.onclick=()=>void e.plugin.openPath(e.plugin.repository.getWorkingMemoryPath(t.name))}var pc=require("obsidian");function mc(a,e){let t=a.createDiv({cls:"af-runs-page"}),s=e.plugin.runtime.getRecentRuns(),n=t.createDiv({cls:"af-runs-toolbar"});n.createDiv({cls:"af-page-title",text:"Run History"}),n.createDiv({cls:"af-toolbar-spacer"});let r=t.createDiv({cls:"af-runs-table"});if(s.length===0){e.renderEmptyState(r,"scroll-text","No runs yet","Run an agent to see history here");return}let i=r.createEl("table"),l=i.createEl("thead").createEl("tr");for(let d of["Status","Agent","Task","Started","Duration","Tokens","Model"])l.createEl("th",{text:d});let c=i.createEl("tbody");for(let d of s.slice(0,50))ap(c,e,d)}function ap(a,e,t){let s=a.createEl("tr"),r=s.createEl("td").createSpan({cls:`af-status-badge ${e.statusToBadgeClass(t.status)}`}),i=r.createSpan();(0,pc.setIcon)(i,e.statusToIconName(t.status)),r.appendText(` ${e.statusToBadgeText(t.status)}`);let o=s.createEl("td",{cls:"af-agent-link"});o.setText(t.agent),o.onclick=l=>{l.stopPropagation(),e.navigate("agent-detail",t.agent)},s.createEl("td",{text:t.task}),s.createEl("td",{cls:"af-mono",text:e.formatStarted(t.started)}),s.createEl("td",{cls:"af-mono",text:e.formatDuration(t.durationSeconds)}),s.createEl("td",{cls:"af-mono",text:t.tokensUsed?t.tokensUsed.toLocaleString():"\u2014"}),s.createEl("td",{cls:"af-mono",text:t.model}),s.setCssStyles({cursor:"pointer"}),s.onclick=()=>e.openSlideover(t)}var fc=require("obsidian");function gc(a,e){let t=a.createDiv({cls:"af-skills-page"}),s=e.plugin.runtime.getSnapshot(),n=t.createDiv({cls:"af-agents-toolbar"});n.createDiv({cls:"af-page-title",text:"Skills Library"}),n.createDiv({cls:"af-toolbar-spacer"});let r=n.createEl("button",{cls:"af-btn-sm primary"});T(r,"plus","af-btn-icon"),r.appendText(" New Skill"),r.onclick=()=>void e.plugin.createSkillTemplate();let i=t.createDiv({cls:"af-skills-grid"});if(s.skills.length===0){e.renderEmptyState(i,"puzzle","No skills yet","Create skills to give agents specialized abilities",{label:"New Skill",onClick:()=>void e.plugin.createSkillTemplate()});return}for(let o of s.skills)rp(i,e,o,s.agents)}function rp(a,e,t,s){let n=a.createDiv({cls:"af-skill-card"}),r=n.createDiv({cls:"af-skill-card-header"}),i=r.createDiv({cls:"af-skill-card-icon"});(0,fc.setIcon)(i,ip(t.name));let o=r.createEl("button",{cls:"af-btn-sm af-btn-xs"});T(o,"edit","af-btn-icon"),o.onclick=c=>{c.stopPropagation(),e.navigate("edit-skill",t.name)},n.createDiv({cls:"af-skill-card-name",text:t.name}),n.createDiv({cls:"af-skill-card-desc",text:t.description??"No description"});let l=s.filter(c=>c.skills.includes(t.name));if(l.length>0){let c=n.createDiv({cls:"af-skill-card-agents"});for(let d of l)c.createSpan({cls:"af-skill-card-agent-tag",text:d.name})}}function ip(a){return a.includes("git")?"settings":a.includes("summarize")||a.includes("log")?"activity":a.includes("review")||a.includes("check")?"check-circle-2":a.includes("vault")||a.includes("note")?"file-text":"puzzle"}var Pa=require("obsidian");function vc(a,e){let t=a.createDiv({cls:"af-approvals-page"}),s=e.plugin.runtime.getRecentRuns(),n=t.createDiv({cls:"af-agents-toolbar"});n.createDiv({cls:"af-page-title",text:"Approvals"}),n.createDiv({cls:"af-toolbar-spacer"});let r=s.filter(o=>(o.approvals??[]).some(l=>l.status==="pending"));if(r.length>0){let o=t.createDiv({cls:"af-section-card"}),c=o.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(c,"alert-triangle"),c.appendText(` Pending (${r.length})`);let d=o.createDiv({cls:"af-approvals-list"});for(let u of r)yc(d,e,u,!0)}else{let o=t.createDiv({cls:"af-section-card"});e.renderEmptyState(o,"shield-check","No pending approvals","All clear!")}let i=s.filter(o=>(o.approvals??[]).some(l=>l.status!=="pending"));if(i.length>0){let o=t.createDiv({cls:"af-section-card"}),c=o.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(c,"check-circle-2"),c.appendText(" History");let d=o.createDiv({cls:"af-approvals-list"});for(let u of i.slice(0,20))yc(d,e,u,!1)}}function yc(a,e,t,s){for(let n of t.approvals??[]){if(s&&n.status!=="pending"||!s&&n.status==="pending")continue;let r=a.createDiv({cls:"af-approval-item"}),i=r.createDiv({cls:"af-approval-item-icon"});n.status==="pending"?((0,Pa.setIcon)(i,"shield-check"),i.addClass("pending")):n.status==="approved"?((0,Pa.setIcon)(i,"check-circle-2"),i.addClass("approved")):((0,Pa.setIcon)(i,"x-circle"),i.addClass("rejected"));let o=r.createDiv({cls:"af-approval-item-body"});if(o.createDiv({cls:"af-approval-item-title",text:`${t.agent} \u2192 ${n.tool}`}),o.createDiv({cls:"af-approval-item-meta",text:`Task: ${t.task} \xB7 ${n.command??"no command"} \xB7 ${e.formatStarted(t.started)}`}),n.reason&&o.createDiv({cls:"af-approval-item-reason",text:`Reason: ${n.reason}`}),s&&n.status==="pending"){let l=r.createDiv({cls:"af-approval-item-actions"}),c=l.createEl("button",{cls:"af-btn-approve"});T(c,"check-circle-2","af-btn-icon"),c.appendText(" Approve"),c.onclick=()=>void e.plugin.runtime.resolveApproval(t,n.tool,"approved").then(()=>e.rerender());let d=l.createEl("button",{cls:"af-btn-reject"});T(d,"x-circle","af-btn-icon"),d.appendText(" Reject"),d.onclick=()=>void e.plugin.runtime.resolveApproval(t,n.tool,"rejected").then(()=>e.rerender())}}}var wc=require("obsidian");function bc(a,e){let t=a.createDiv({cls:"af-agents-page"}),s=e.plugin.runtime.getSnapshot(),n=t.createDiv({cls:"af-agents-toolbar"});n.createDiv({cls:"af-page-title",text:"Channels"}),n.createDiv({cls:"af-toolbar-spacer"});let r=n.createEl("button",{cls:"af-btn-sm primary"});T(r,"plus","af-btn-icon"),r.appendText(" New Channel"),r.onclick=()=>e.navigate("create-channel");let i=t.createDiv({cls:"af-agents-grid"});if(s.channels.length===0){e.renderEmptyState(i,"radio","No channels configured","Connect an agent to Slack or another chat platform",{label:"New Channel",onClick:()=>e.navigate("create-channel")});return}for(let o of s.channels)op(i,e,o,s.validationIssues)}function op(a,e,t,s){let n=e.plugin.channelManager?.getChannelStatus(t.name)??"disabled",r=Ur(n),i=t.enabled&&n!=="disabled"?"af-agent-card":"af-agent-card disabled",o=a.createDiv({cls:i});o.setCssStyles({cursor:"default"});let l=o.createDiv({cls:"af-agent-card-header"}),c=l.createDiv({cls:`af-agent-card-avatar ${r}`});(0,wc.setIcon)(c,"radio");let d=l.createDiv({cls:"af-agent-card-titleblock"});d.createDiv({cls:"af-agent-card-name",text:t.name}),d.createDiv({cls:"af-agent-card-desc",text:`Default: ${t.defaultAgent}`});let u=l.createSpan({cls:`af-pill ${lp(n)}`});if(u.createSpan({cls:"af-dot"}),u.appendText(` ${n}`),t.allowedAgents.length>0){let b=o.createDiv({cls:"af-agent-card-skills"});for(let P of t.allowedAgents){let A=b.createSpan({cls:"af-skill-tag",text:P});P===t.defaultAgent&&A.setCssStyles({fontWeight:"700"})}}let h=o.createDiv({cls:"af-agent-card-stats"}),p=e.plugin.channelManager?.getSessionCount(t.name)??0,m=e.plugin.channelManager?.getMetrics(t.name),f=t.allowedAgents.length>0?String(t.allowedAgents.length):"all";e.renderAgentStat(h,f,"Agents"),e.renderAgentStat(h,String(p),"Sessions"),e.renderAgentStat(h,String(m?.messagesReceived??0),"In"),e.renderAgentStat(h,String(m?.messagesSent??0),"Out");let g=o.createDiv({cls:"af-agent-card-footer"}),w=[t.type];t.enabled||w.push("disabled"),t.allowedUsers.length>0?w.push(`${t.allowedUsers.length} user(s)`):w.push("allowlist empty"),g.createSpan({cls:"af-agent-card-meta",text:w.join(" \xB7 ")});let v=g.createDiv({cls:"af-agent-card-actions"}).createEl("button",{cls:"af-btn-sm"});T(v,"edit","af-btn-icon"),v.appendText(" Edit"),v.onclick=b=>{b.stopPropagation(),e.navigate("edit-channel",t.name)};let k=s.filter(b=>b.path===t.filePath);if(k.length>0){let b=o.createDiv({cls:"af-channel-issues"});for(let P of k)b.createDiv({cls:"af-channel-issue-row",text:P.message})}}function lp(a){switch(a){case"connected":return"green";case"connecting":case"reconnecting":return"blue";case"needs-auth":case"error":return"red";case"stopped":case"disabled":default:return""}}var dn=require("obsidian");function kc(a){let e=/^##\s+Lint\s+(\d{4}-\d{2}-\d{2})\s*$/gm,t,s=-1,n="";for(;(t=e.exec(a))!==null;)t.index>s&&(s=t.index,n=t[1]??"");if(s<0)return null;let r=a.slice(s),i=r.search(/\n##\s+(?!\s*#)/),o=i<0?r:r.slice(0,i);return{date:n,summary:Ea(o,"Summary"),autoApplied:Ea(o,"Auto-applied"),needsReview:Ea(o,"Needs review"),refreshChained:Ea(o,"Refresh chained")}}function Ea(a,e){let t=e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),n=new RegExp(`^###\\s+${t}\\s*$`,"m").exec(a);if(!n)return[];let r=n.index+n[0].length,i=a.slice(r),o=i.search(/\n###\s+/),c=(o<0?i:i.slice(0,o)).split(/\r?\n/).map(u=>u.trimEnd()),d=[];for(let u of c){let h=u.replace(/^\s+/,"");h.startsWith("- ")?d.push(h.slice(2).trim()):d.length>0&&(u.startsWith(" ")||u.startsWith(" "))&&(d[d.length-1]+=" "+h)}return d}async function xc(a,e){let t=a.createDiv({cls:"af-agents-page"}),s=e.plugin.runtime.getSnapshot(),n=t.createDiv({cls:"af-agents-toolbar"});n.createDiv({cls:"af-page-title",text:"Wiki Keepers"}),n.createDiv({cls:"af-toolbar-spacer"});let r=s.agents.filter(o=>o.wikiKeeper!==void 0);if(r.length===0){e.renderEmptyState(t,"library","No Wiki Keepers yet","Open Settings \u2192 Agent Fleet \u2192 Wiki Keepers \u2192 + Add to create one.");return}let i=t.createDiv({cls:"af-wk-list"});i.setCssStyles({display:"flex"}),i.setCssStyles({flexDirection:"column"}),i.setCssStyles({gap:"16px"});for(let o of r)await cp(i,e,o)}async function cp(a,e,t){let s=t.wikiKeeper,n=a.createDiv({cls:"af-card"});n.setCssStyles({padding:"16px"}),n.setCssStyles({border:"1px solid var(--background-modifier-border)"}),n.setCssStyles({borderRadius:"8px"});let r=n.createDiv();r.setCssStyles({display:"flex"}),r.setCssStyles({alignItems:"center"}),r.setCssStyles({gap:"12px"}),r.setCssStyles({marginBottom:"12px"});let i=r.createDiv();i.setCssStyles({flex:"1"}),i.createEl("strong",{text:t.name});let o=s.scopeRoot||"(whole vault)";i.createEl("div",{text:`Scope: ${o} \xB7 topics: ${s.topicsRoot}/ \xB7 log: ${s.logPath}`,cls:"af-form-hint"});let l=r.createEl("button",{cls:"af-btn-sm"});l.appendText("Open log"),l.onclick=()=>{let f=s.scopeRoot?`${s.scopeRoot}/${s.logPath}`:s.logPath,g=e.plugin.app.vault.getAbstractFileByPath(f);g instanceof dn.TFile?e.plugin.app.workspace.getLeaf().openFile(g):new dn.Notice(`Log file not found: ${f}`)};let c=s.scopeRoot?`${s.scopeRoot}/${s.logPath}`:s.logPath,d=e.plugin.app.vault.getAbstractFileByPath(c),u=null;if(d instanceof dn.TFile)try{let f=await e.plugin.app.vault.cachedRead(d);u=kc(f)}catch{u=null}if(!u){n.createDiv({cls:"af-form-hint"}).setText("No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.");return}let h=n.createDiv();if(h.setCssStyles({display:"flex"}),h.setCssStyles({alignItems:"baseline"}),h.setCssStyles({gap:"12px"}),h.setCssStyles({marginBottom:"8px"}),h.createEl("strong",{text:`Lint ${u.date}`}),h.createSpan({cls:"af-form-hint",text:`${u.summary.length} summary lines \xB7 ${u.autoApplied.length} auto-applied \xB7 ${u.needsReview.length} needs review`}),u.summary.length>0){let f=n.createEl("details");f.createEl("summary",{text:"Summary"});let g=f.createEl("ul");g.setCssStyles({marginTop:"4px"});for(let w of u.summary)g.createEl("li",{text:w})}if(u.autoApplied.length>0){let f=n.createEl("details");f.createEl("summary",{text:`Auto-applied (${u.autoApplied.length})`});let g=f.createEl("ul");g.setCssStyles({marginTop:"4px"});for(let w of u.autoApplied)g.createEl("li",{text:w})}if(u.refreshChained.length>0){let f=n.createEl("details");f.createEl("summary",{text:`Refresh chained (${u.refreshChained.length})`});let g=f.createEl("ul");g.setCssStyles({marginTop:"4px"});for(let w of u.refreshChained)g.createEl("li",{text:w})}let p=n.createDiv();if(p.setCssStyles({marginTop:"12px"}),p.createEl("strong",{text:`Needs review (${u.needsReview.length})`}),u.needsReview.length===0){p.createDiv({cls:"af-form-hint",text:"All clear. Nothing requires manual review from this lint pass."});return}let m=p.createDiv();m.setCssStyles({display:"flex"}),m.setCssStyles({flexDirection:"column"}),m.setCssStyles({gap:"6px"}),m.setCssStyles({marginTop:"8px"});for(let f of u.needsReview){let g=m.createDiv();g.setCssStyles({display:"flex"}),g.setCssStyles({alignItems:"flex-start"}),g.setCssStyles({gap:"8px"}),g.setCssStyles({padding:"8px 10px"}),g.setCssStyles({background:"var(--background-secondary)"}),g.setCssStyles({borderRadius:"4px"}),g.setCssStyles({fontSize:"13px"});let w=g.createDiv();w.setCssStyles({flex:"1"}),w.setText(f);let y=g.createEl("button",{cls:"af-btn-sm",text:"Dismiss"});y.title="Hide this item from the dashboard until the next lint pass (does not modify log.md).",y.onclick=()=>{g.remove()}}}var Sc=require("obsidian");function Cc(a,e,t){let s=a.createDiv({cls:"af-task-detail-page"});if(!t){e.renderEmptyState(s,"circle-dot","No task selected","");return}let n=e.plugin.runtime.getSnapshot().tasks.find(_=>_.taskId===t);if(!n){e.renderEmptyState(s,"circle-dot","Task not found",`Task "${t}" was not found`);return}let r=e.plugin.runtime.getSnapshot(),i=e.plugin.runtime.getRecentRuns().filter(_=>_.task===t),o=r.agents.find(_=>_.name===n.agent),l=s.createDiv({cls:"af-detail-header"}),c=l.createDiv({cls:"af-detail-header-left"}),d=c.createDiv({cls:"af-agent-card-avatar idle"});(0,Sc.setIcon)(d,"circle-dot");let u=c.createDiv();u.createDiv({cls:"af-detail-header-name",text:n.taskId}),u.createDiv({cls:"af-detail-header-desc",text:`Agent: ${n.agent}`});let h=l.createDiv({cls:"af-detail-header-actions"}),p=h.createEl("button",{cls:"af-btn-sm"});T(p,"edit","af-btn-icon"),p.appendText(" Edit"),p.onclick=()=>e.navigate("edit-task",n.taskId);let m=h.createEl("button",{cls:"af-btn-sm primary"});T(m,"play","af-btn-icon"),m.appendText(" Run Now"),m.onclick=()=>void e.plugin.runtime.runTaskNow(n);let f=s.createDiv({cls:"af-section-card"}),w=f.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(w,"file-text"),w.appendText(" Details");let y=f.createDiv({cls:"af-config-form"});e.renderConfigRow(y,"Agent",n.agent),e.renderConfigRow(y,"Priority",n.priority.charAt(0).toUpperCase()+n.priority.slice(1)),e.renderConfigRow(y,"Status",n.enabled?"Enabled":"Disabled");let v=n.schedule?e.humanizeCron(n.schedule):n.runAt??"Manual (run on demand)";e.renderConfigRow(y,"Schedule",v),n.schedule&&e.renderConfigRow(y,"Catch up if missed",n.catchUp?"Yes":"No"),e.renderConfigRow(y,"Created",n.created),e.renderConfigRow(y,"Runs",String(n.runCount)),n.lastRun&&e.renderConfigRow(y,"Last Run",e.formatStarted(n.lastRun));let k=s.createDiv({cls:"af-section-card"}),P=k.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(P,"message-square"),P.appendText(" Instructions"),k.createDiv({cls:"af-output-block",text:n.body||"(empty)"});let A=s.createDiv({cls:"af-section-card"}),E=A.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(E,"scroll-text"),E.appendText(" Recent Runs");let x=A.createDiv({cls:"af-timeline"});if(i.length===0)e.renderEmptyState(x,"scroll-text","No runs yet","");else for(let _ of i.slice(0,10))e.renderTimelineItem(x,_)}var we=require("obsidian");function Tc(a,e){let t=a.createDiv({cls:"af-agents-page"}),s=t.createDiv({cls:"af-agents-toolbar"});s.createDiv({cls:"af-page-title",text:"MCP Servers"}),s.createDiv({cls:"af-toolbar-spacer"});let n=s.createEl("button",{cls:"af-btn-sm primary"});T(n,"plus","af-btn-icon"),n.appendText(" Add Server"),n.onclick=()=>e.navigate("add-mcp-server");let r=e.plugin.repository.getMcpServers();if(r.length===0){e.renderEmptyState(t,"plug","No MCP servers registered","Click 'Add Server' above to register one.");return}let i=t.createDiv({cls:"af-agents-grid"});for(let o of r)up(i,e,o)}function Wr(a,e){return a.plugin.mcpAuth.hasToken(e.name)}function dp(a,e){return e.type!=="stdio"&&(e.auth==="oauth"||e.auth==="bearer")&&!Wr(a,e)}function up(a,e,t){let s=a.createDiv({cls:`af-mcp-card${t.enabled?"":" af-mcp-card-disabled"}`}),n=dp(e,t),r=s.createDiv({cls:"af-agent-card-header"}),i=t.enabled?n?"pending":"idle":"disabled",o=r.createDiv({cls:`af-agent-card-avatar ${i}`});(0,we.setIcon)(o,"plug");let l=r.createDiv({cls:"af-agent-card-titleblock"});l.createDiv({cls:"af-agent-card-name",text:t.name});let c=l.createDiv({cls:"af-agent-card-desc af-mcp-meta"});c.createSpan({cls:"af-mcp-type-badge",text:t.type}),t.source==="imported"&&c.createSpan({cls:"af-badge",text:"imported"});let d=r.createDiv({cls:`af-agent-card-toggle${t.enabled?" on":""}`});d.onclick=f=>{f.stopPropagation(),e.plugin.repository.setMcpServerEnabled(t.name,!t.enabled).then(async()=>{await e.plugin.refreshFromVault(),e.rerender()})};let u=s.createDiv({cls:`af-mcp-status-badge ${t.enabled?n?"needs-auth":"connected":"disabled"}`}),h=u.createSpan();if(t.enabled?n?((0,we.setIcon)(h,"alert-circle"),u.createSpan({text:" Needs auth"})):((0,we.setIcon)(h,"check-circle"),u.createSpan({text:t.type==="stdio"?" Enabled":" Authenticated"})):((0,we.setIcon)(h,"pause"),u.createSpan({text:" Disabled"})),t.description){let f=Ac(t.description,120);s.createDiv({cls:"af-mcp-description",text:f})}let p=t.url??t.command??"";p&&s.createDiv({cls:"af-mcp-command",text:At(p,60)});let m=e.mcpProbeCache.get(t.name);if(m&&m.length>0){let f=s.createDiv({cls:"af-mcp-tool-footer"}),g=f.createDiv({cls:"af-mcp-tool-count"}),w=g.createSpan();(0,we.setIcon)(w,"wrench"),g.createSpan({text:` ${m.length} tools`});let y=f.createDiv({cls:"af-mcp-tool-chips"});for(let v of m.slice(0,4))y.createSpan({cls:"af-mcp-tool-chip",text:v.name});m.length>4&&y.createSpan({cls:"af-mcp-tool-chip af-mcp-tool-chip-more",text:`+${m.length-4}`})}if(e.authenticatingServers.has(t.name)){let g=s.createDiv({cls:"af-mcp-auth-row"}).createEl("button",{cls:"af-btn-sm primary",attr:{disabled:"true"}}),w=g.createSpan({cls:"af-spin"});(0,we.setIcon)(w,"loader-2"),g.appendText(" Authenticating\u2026")}else if(t.enabled&&n&&t.auth==="oauth"){let g=s.createDiv({cls:"af-mcp-auth-row"}).createEl("button",{cls:"af-btn-sm primary"}),w=g.createSpan();(0,we.setIcon)(w,"key"),g.appendText(" Authenticate"),g.onclick=y=>{y.stopPropagation(),_c(e,t)}}s.onclick=()=>Pc(e,t)}async function _c(a,e){if(!e.url){new we.Notice("No URL found for this server \u2014 can't authenticate.");return}a.authenticatingServers.add(e.name),a.rerender(),new we.Notice(`Authenticating ${e.name}\u2026 Complete authorization in your browser.`,1e4);try{let t=e.type==="sse"?"sse":"http";await a.plugin.mcpManager.authenticateServer(e.name,e.url,t),new we.Notice(`${e.name} authenticated successfully!`)}catch(t){let s=t instanceof Error?t.message:String(t);new we.Notice(`Authentication failed: ${s}`,8e3)}finally{a.authenticatingServers.delete(e.name),a.rerender()}}async function hp(a,e){try{let t=await a.plugin.mcpManager.probeServer(e);a.mcpProbeCache.set(e.name,t),t.length===0&&new we.Notice(`No tools discovered for ${e.name}.`)}catch(t){let s=t instanceof Error?t.message:String(t);new we.Notice(`Probe failed: ${At(s,150)}`)}}function Ac(a,e){let t=re(a)[0]??a,s=t.split(/(?<=[.!?])\s/)[0]??t,n=s.lengtht.remove(),t.onclick=g=>{g.target===t&&t.remove()};let i=s.createDiv({cls:"af-slideover-body"});if(e.description){let g=i.createDiv({cls:"af-slideover-section"});g.createDiv({cls:"af-slideover-section-title",text:"DESCRIPTION"}),g.createDiv({cls:"af-mcp-detail-description",text:e.description})}let o=i.createDiv({cls:"af-slideover-section"});o.createDiv({cls:"af-slideover-section-title",text:"SERVER INFO"}),a.renderDetailRow(o,"Name",e.name),a.renderDetailRow(o,"Transport",e.type),a.renderDetailRow(o,"Enabled",e.enabled?"yes":"no"),e.type!=="stdio"&&(a.renderDetailRow(o,"Auth",e.auth??"none"),a.renderDetailRow(o,"Authenticated",Wr(a,e)?"yes":"no")),e.source&&a.renderDetailRow(o,"Source",e.source),e.url&&a.renderDetailRow(o,"URL",e.url),e.command&&a.renderDetailRow(o,"Command",e.command),e.args&&e.args.length>0&&a.renderDetailRow(o,"Args",e.args.join(" "));let l=a.mcpProbeCache.get(e.name)??[],c=i.createDiv({cls:"af-slideover-section"});c.createDiv({cls:"af-slideover-section-title"}).setText(`TOOLS (${l.length})`);let u=c.createEl("button",{cls:"af-btn-sm"}),h=u.createSpan();if((0,we.setIcon)(h,"wrench"),u.appendText(" Probe tools"),u.onclick=async()=>{u.disabled=!0,u.setText(" Probing\u2026"),await hp(a,e),t.remove(),Pc(a,e)},l.length>0)for(let g of l){let w=c.createDiv({cls:"af-mcp-tool-detail"}),y=w.createDiv({cls:"af-mcp-tool-detail-header"}),v=y.createSpan({cls:"af-mcp-tool-detail-name"}),k=v.createSpan();if((0,we.setIcon)(k,"wrench"),v.createSpan({text:` ${g.name}`}),g.inputSchema){let b=g.inputSchema.required??[];b.length>0&&y.createSpan({cls:"af-mcp-tool-param-count",text:`${b.length} param${b.length!==1?"s":""}`})}if(g.description){let b=re(g.description).filter(M=>M.trim()),P=b.slice(0,2).join(" ").trim();if(b.length>2){let M=w.createEl("details",{cls:"af-mcp-tool-detail-desc"});M.createEl("summary",{text:Ac(P,200)}),M.createDiv({cls:"af-mcp-tool-detail-full",text:g.description})}else w.createDiv({cls:"af-mcp-tool-detail-desc",text:P})}if(g.inputSchema){let b=g.inputSchema.properties,P=new Set(g.inputSchema.required??[]);if(b&&Object.keys(b).length>0){let A=w.createDiv({cls:"af-mcp-tool-params"});for(let[M,E]of Object.entries(b)){let x=A.createDiv({cls:"af-mcp-tool-param"});x.createSpan({cls:"af-mcp-tool-param-name",text:M}),E.type&&x.createSpan({cls:"af-mcp-tool-param-type",text:E.type}),P.has(M)&&x.createSpan({cls:"af-mcp-tool-param-required",text:"required"}),E.description&&x.createSpan({cls:"af-mcp-tool-param-desc",text:At(E.description,80)})}}}}else c.createDiv({cls:"af-form-hint",text:'Click "Probe tools" to discover the tools this server exposes.'});let p=i.createDiv({cls:"af-slideover-section"});if(p.createDiv({cls:"af-slideover-section-title",text:"ACTIONS"}),e.enabled&&e.url&&e.auth==="oauth"&&!Wr(a,e)){let g=p.createEl("button",{cls:"af-btn-sm primary"}),w=g.createSpan();(0,we.setIcon)(w,"key"),g.appendText(" Authenticate"),g.onclick=()=>{t.remove(),_c(a,e)}}let m=p.createEl("button",{cls:"af-btn-sm danger"}),f=m.createSpan();(0,we.setIcon)(f,"trash-2"),m.appendText(" Remove Server"),m.onclick=async()=>{try{await a.plugin.repository.deleteMcpServer(e.name),a.plugin.mcpAuth.removeToken(e.name),a.mcpProbeCache.delete(e.name),new we.Notice(`Server "${e.name}" removed.`),t.remove(),await a.plugin.refreshFromVault(),a.rerender()}catch(g){let w=g instanceof Error?g.message:String(g);new we.Notice(`Failed to remove server: ${w}`)}}}var Ec={dashboard:"Dashboard",agents:"Agents",kanban:"Tasks Board",runs:"Run History",skills:"Skills Library",approvals:"Approvals",mcp:"MCP Servers",channels:"Channels","wiki-keepers":"Wiki Keepers","agent-detail":"Agent Details","task-detail":"Task Details","create-agent":"Create Agent","create-task":"Create Task","create-skill":"Create Skill","edit-agent":"Edit Agent","edit-task":"Edit Task","edit-skill":"Edit Skill","create-channel":"Create Channel","edit-channel":"Edit Channel","add-mcp-server":"Add MCP Server"},pp={dashboard:"layout-dashboard",agents:"bot",kanban:"columns-3",runs:"scroll-text",skills:"puzzle",approvals:"shield-check",mcp:"plug",channels:"radio","wiki-keepers":"library","agent-detail":"bot","task-detail":"circle-dot","create-agent":"plus","create-task":"plus","create-skill":"plus","edit-agent":"edit","edit-task":"edit","edit-skill":"edit","create-channel":"plus","edit-channel":"edit","add-mcp-server":"plus"},mp=["dashboard","agents","kanban","runs","approvals","skills","wiki-keepers","mcp","channels"],Dc="agent-fleet-welcome-dismissed",fp=new Set(Ls.map(a=>/^agents\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(a.path)?.[1]).filter(a=>!!a)),un=class extends Z.ItemView{constructor(t,s){super(t);this.plugin=s}currentPage="dashboard";detailContext;agentDetailTab="overview";streamingUnsubscribes=[];runningCardTickers=[];runningCardInterval;channelStatusUnsubscribe;authenticatingServers=new Set;mcpProbeCache=new Map;getViewType(){return Yt}getDisplayText(){return"Agent Fleet"}getIcon(){return"bot"}async onOpen(){this.plugin.subscribeView(this),this.channelStatusUnsubscribe=this.plugin.channelManager?.onStatusChange(()=>{this.currentPage==="channels"&&this.render()}),await this.render()}async onClose(){this.cleanupStreaming(),this.channelStatusUnsubscribe?.(),this.channelStatusUnsubscribe=void 0,this.plugin.unsubscribeView(this)}navigateTo(t,s){this.currentPage=t,this.detailContext=s,t!=="agent-detail"&&(this.agentDetailTab="overview"),this.render()}async render(){this.cleanupStreaming();let t=this.contentEl;t.empty(),t.addClass("af-root");let n=t.createDiv({cls:"af-app"}).createDiv({cls:"af-main-content"});this.renderTopBar(n),this.renderTabBar(n);let r=n.createDiv({cls:"af-page"});switch(this.currentPage){case"dashboard":this.renderDashboardPage(r);break;case"agents":dc(r,this.agentsPageDeps());break;case"kanban":this.renderKanbanPage(r);break;case"runs":mc(r,this.runsPageDeps());break;case"skills":gc(r,this.skillsPageDeps());break;case"approvals":vc(r,this.approvalsPageDeps());break;case"mcp":Tc(r,this.mcpPageDeps());break;case"channels":bc(r,this.channelsPageDeps());break;case"wiki-keepers":xc(r,this.basePageDeps());break;case"agent-detail":uc(r,this.agentDetailPageDeps(),this.detailContext);break;case"task-detail":Cc(r,this.taskDetailPageDeps(),this.detailContext);break;case"create-agent":this.renderCreateAgentPage(r);break;case"create-task":this.renderCreateTaskPage(r);break;case"create-skill":this.renderCreateSkillPage(r);break;case"edit-agent":this.renderEditAgentPage(r);break;case"edit-task":this.renderEditTaskPage(r);break;case"edit-skill":this.renderEditSkillPage(r);break;case"create-channel":this.renderCreateChannelPage(r);break;case"edit-channel":this.renderEditChannelPage(r);break;case"add-mcp-server":this.renderAddMcpServerPage(r);break}}navigate(t,s){this.navigateTo(t,s)}cleanupStreaming(){for(let t of this.streamingUnsubscribes)t();this.streamingUnsubscribes=[],this.runningCardTickers=[],this.runningCardInterval!==void 0&&(window.clearInterval(this.runningCardInterval),this.runningCardInterval=void 0)}renderTopBar(t){let s=t.createDiv({cls:"af-top-bar"}),n=s.createDiv({cls:"af-top-bar-title"});T(n,"bot","af-top-bar-icon"),n.createSpan({text:"Agent Fleet"});let r=s.createDiv({cls:"af-breadcrumb"}),i=r.createSpan({cls:"af-breadcrumb-sep"});(0,Z.setIcon)(i,"chevron-right");let o=(f,g,w)=>{let y=r.createSpan({cls:g?"af-breadcrumb-link":"",text:f});g&&(y.onclick=()=>this.navigate(g,w))},l=()=>{let f=r.createSpan({cls:"af-breadcrumb-sep"});(0,Z.setIcon)(f,"chevron-right")};switch(this.currentPage){case"agent-detail":o("Agents","agents"),l(),o(this.detailContext??"Agent");break;case"task-detail":o("Tasks Board","kanban"),l(),o(this.detailContext??"Task");break;case"edit-agent":o("Agents","agents"),l(),o(this.detailContext??"Agent","agent-detail",this.detailContext),l(),o("Edit");break;case"edit-task":o("Tasks Board","kanban"),l(),o(this.detailContext??"Task","task-detail",this.detailContext),l(),o("Edit");break;case"create-agent":o("Agents","agents"),l(),o("New Agent");break;case"create-task":o("Tasks Board","kanban"),l(),o("New Task");break;case"create-skill":o("Skills Library","skills"),l(),o("New Skill");break;case"edit-skill":o("Skills Library","skills"),l(),o(this.detailContext??"Skill"),l(),o("Edit");break;case"create-channel":o("Channels","channels"),l(),o("New Channel");break;case"edit-channel":o("Channels","channels"),l(),o(this.detailContext??"Channel"),l(),o("Edit");break;case"add-mcp-server":o("MCP Servers","mcp"),l(),o("Add Server");break;default:o(Ec[this.currentPage])}s.createDiv({cls:"af-top-bar-spacer"});let c=s.createDiv({cls:"af-search-wrap"});T(c,"search","af-search-icon");let d=c.createEl("input",{cls:"af-search-input",attr:{type:"text",placeholder:"Search agents, tasks, runs..."}}),u;d.addEventListener("input",()=>{window.clearTimeout(u),u=window.setTimeout(()=>{this.handleSearch(d.value,c)},200)}),d.addEventListener("blur",()=>{window.setTimeout(()=>c.querySelector(".af-search-results")?.remove(),200)});let h=this.plugin.runtime.getFleetStatus(),p=s.createDiv({cls:"af-status-pills"});if(h.running>0){let f=p.createSpan({cls:"af-pill yellow"});f.createSpan({cls:"af-dot pulse"}),f.appendText(` ${h.running} Running`)}if(h.pending>0){let f=p.createSpan({cls:"af-pill blue"});f.createSpan({cls:"af-dot"}),f.appendText(` ${h.pending} Pending`)}let m=p.createSpan({cls:"af-pill green"});m.createSpan({cls:"af-dot"}),m.appendText(` ${h.completedToday} Today`)}renderTabBar(t){let s=t.createDiv({cls:"af-tab-bar"}),n=this.plugin.runtime.getSnapshot(),r=this.plugin.runtime.getFleetStatus();for(let i of mp){let o=this.currentPage===i||i==="agents"&&(this.currentPage==="agent-detail"||this.currentPage==="edit-agent"||this.currentPage==="create-agent")||i==="kanban"&&(this.currentPage==="task-detail"||this.currentPage==="edit-task")||i==="skills"&&(this.currentPage==="edit-skill"||this.currentPage==="create-skill")||i==="mcp"&&this.currentPage==="add-mcp-server",l=s.createEl("button",{cls:`af-tab-item${o?" active":""}`}),c=l.createSpan({cls:"af-tab-icon"});if((0,Z.setIcon)(c,pp[i]),l.appendText(i==="dashboard"?"Overview":Ec[i]),i==="agents")l.createSpan({cls:"af-badge",text:String(n.agents.length)});else if(i==="skills")l.createSpan({cls:"af-badge",text:String(n.skills.length)});else if(i==="mcp"){let d=this.plugin.repository.getMcpServers().length;l.createSpan({cls:"af-badge",text:String(d)})}else i==="approvals"&&r.pending>0&&l.createSpan({cls:"af-badge af-badge-warn",text:String(r.pending)});l.onclick=()=>this.navigate(i)}}renderDashboardPage(t){let s=t.createDiv({cls:"af-dashboard"}),n=this.plugin.runtime.getSnapshot(),r=this.plugin.runtime.getRecentRuns(),i=this.plugin.runtime.getFleetStatus();this.maybeRenderWelcomeCard(s,n.agents,r);let o=r.filter(b=>(b.approvals??[]).some(P=>P.status==="pending"));for(let b of o)for(let P of b.approvals??[])P.status==="pending"&&this.renderApprovalBanner(s,b,P.tool);let l=s.createDiv({cls:"af-dash-grid"}),c=n.agents.filter(b=>b.enabled).length,d=n.agents.length;this.renderStatCard(l,"Active Agents",`${c}`,`/ ${d}`,"bot",`${c} of ${d} enabled`);let u=this.toLocalDateStr(new Date),h=r.filter(b=>this.runToLocalDate(b.started)===u),p=h.filter(b=>b.status==="success").length,m=h.filter(b=>b.status==="failure"||b.status==="timeout").length;this.renderStatCard(l,"Runs Today",String(h.length),"","activity",`${p} passed \xB7 ${m} failed \xB7 ${i.running} running`);let f=this.plugin.runtime.getUsageRecords().filter(b=>this.runToLocalDate(b.ts)===u),{tokens:g,cost:w}=this.combinedTotals(h,f),y=w>0?` \xB7 $${w.toFixed(2)}`:"";this.renderStatCard(l,"Tokens Used",qr(g),"","zap",`today${y}`);let v=n.tasks.filter(b=>b.enabled&&b.schedule);this.renderStatCard(l,"Scheduled Tasks",String(v.length),"","clock",v.length>0?`Next: ${this.getNextTaskLabel(v)}`:"No scheduled tasks"),this.renderChartsRow(s,r,this.plugin.runtime.getChartRuns()),this.renderStreamingCards(s);let k=s.createDiv({cls:"af-dash-split"});this.renderActivityTimeline(k,r),this.renderFleetStatusPanel(k,n)}maybeRenderWelcomeCard(t,s,n){if(this.plugin.app.loadLocalStorage(Dc)||!(s.length>0&&s.every(p=>fp.has(p.name))&&n.length===0))return;let i=t.createDiv({cls:"af-welcome-card"}),o=i.createDiv({cls:"af-welcome-header"}),l=o.createDiv({cls:"af-section-title"});T(l,"sparkles"),l.appendText(" Welcome to Agent Fleet");let c=o.createEl("button",{cls:"clickable-icon af-welcome-dismiss",attr:{"aria-label":"Dismiss"}});(0,Z.setIcon)(c,"x"),c.onclick=()=>{this.plugin.app.saveLocalStorage(Dc,"1"),i.remove()},i.createDiv({cls:"af-welcome-sub",text:"Three quick steps to get your fleet going:"});let d=i.createDiv({cls:"af-welcome-steps"}),u=(p,m,f,g,w)=>{let y=d.createDiv({cls:"af-welcome-step"});y.createDiv({cls:"af-welcome-step-num",text:String(p)});let v=y.createDiv({cls:"af-welcome-step-body"}),k=v.createDiv({cls:"af-welcome-step-title"});T(k,m,"af-meta-icon"),k.appendText(` ${f}`),v.createDiv({cls:"af-welcome-step-desc",text:g}),y.onclick=w},h=s.find(p=>p.name==="fleet-orchestrator")??s[0];u(1,"message-circle","Try the Fleet Orchestrator in Chat","It knows this plugin inside out \u2014 ask it to build agents, tasks, and skills for you.",()=>void this.plugin.openChatView(h?.name)),u(2,"bot","Create your own agent","Give it a system prompt, a model, and permissions.",()=>this.navigate("create-agent")),u(3,"radio","Connect a channel (optional)","Talk to your agents from Slack, Telegram, or Discord.",()=>this.navigate("channels"))}renderChartsRow(t,s,n){let r=t.createDiv({cls:"af-charts-row"}),i=r.createDiv({cls:"af-section-card af-chart-section"}),l=i.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(l,"activity"),l.appendText(" Run Activity (14d)");let c=i.createDiv({cls:"af-chart-body"}),d=this.buildChartData(n,14);d.some(w=>w.success+w.failure+w.cancelled>0)?Gl(c,d):this.renderEmptyState(c,"activity","No run data","Run agents to see activity");let u=r.createDiv({cls:"af-section-card af-chart-section"}),p=u.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(p,"target"),p.appendText(" Success Rate");let m=u.createDiv({cls:"af-chart-body af-chart-body-center"}),f=s.length,g=s.filter(w=>w.status==="success").length;f>0?Vl(m,g,f):this.renderEmptyState(m,"target","No data","")}toLocalDateStr(t){let s=n=>String(n).padStart(2,"0");return`${t.getFullYear()}-${s(t.getMonth()+1)}-${s(t.getDate())}`}runToLocalDate(t){return this.toLocalDateStr(new Date(t))}runCost(t){return t.costUsd??ql(t.model,t.tokensUsed??0)}usageCost(t){return t.costUsd??Wl(t.model,{inputTokens:t.inputTokens,outputTokens:t.outputTokens,cacheReadTokens:t.cacheReadTokens,cacheCreateTokens:t.cacheCreateTokens})}combinedTotals(t,s){let n=t.reduce((i,o)=>i+(o.tokensUsed??0),0)+s.reduce((i,o)=>i+o.totalTokens,0),r=t.reduce((i,o)=>i+this.runCost(o),0)+s.reduce((i,o)=>i+this.usageCost(o),0);return{tokens:n,cost:r}}buildChartData(t,s){let n=[],r=new Date;for(let i=s-1;i>=0;i--){let o=new Date(r);o.setDate(o.getDate()-i);let l=this.toLocalDateStr(o),c=t.filter(d=>this.runToLocalDate(d.started)===l);n.push({date:l,success:c.filter(d=>d.status==="success").length,failure:c.filter(d=>d.status==="failure"||d.status==="timeout").length,cancelled:c.filter(d=>d.status==="cancelled").length})}return n}renderStreamingCards(t){let n=this.plugin.runtime.getSnapshot().agents.filter(l=>this.plugin.runtime.getAgentState(l.name).status==="running");if(n.length===0)return;let r=t.createDiv({cls:"af-streaming-section"}),o=r.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(o,"terminal"),o.appendText(" Active Agents");for(let l of n)this.renderStreamingCard(r,l.name)}renderStreamingCard(t,s){let n=t.createDiv({cls:"af-streaming-card"}),r=this.plugin.runtime.getAgentState(s),i=this.plugin.runtime.getSnapshot(),o=r.currentTaskId?i.tasks.find(m=>m.taskId===r.currentTaskId):void 0,l=o?` \u2192 ${o.taskId}`:r.currentTaskId?.startsWith("reflection-")?" \u2192 Reflection":r.currentTaskId?.startsWith("heartbeat-")||r.status==="running"?" \u2192 Heartbeat":"",c=n.createDiv({cls:"af-streaming-card-header"});c.createSpan({cls:"af-dot pulse",attr:{style:"background: var(--af-yellow)"}}),c.createSpan({cls:"af-streaming-card-agent",text:` ${s}`}),l&&c.createSpan({cls:"af-streaming-card-task",text:l});let d=n.createDiv({cls:"af-streaming-output"}),u=r.currentTaskId?.startsWith("reflection-")?"Consolidating memory\u2026":"Working\u2026",h=m=>{let f=re(m).filter(g=>g.trim().length>0).slice(-4);d.setText(f.length>0?f.join(` +`):u)};h(this.plugin.runtime.getRunOutputBuffer(s));let p=this.plugin.runtime.onRunOutput(s,()=>{h(this.plugin.runtime.getRunOutputBuffer(s)),d.scrollTop=d.scrollHeight});this.streamingUnsubscribes.push(p)}renderApprovalBanner(t,s,n){let r=t.createDiv({cls:"af-approval-banner"}),i=r.createDiv({cls:"af-approval-icon"});(0,Z.setIcon)(i,"shield-check");let o=r.createDiv({cls:"af-approval-text"});o.createDiv({cls:"af-approval-title",text:`${s.agent} wants to run: ${n}`}),o.createDiv({cls:"af-approval-desc",text:`Approval required for tool: ${n}`});let l=r.createDiv({cls:"af-approval-actions"}),c=l.createEl("button",{cls:"af-btn-approve",text:"Approve"});c.onclick=()=>void this.plugin.runtime.resolveApproval(s,n,"approved").then(()=>this.render());let d=l.createEl("button",{cls:"af-btn-reject",text:"Reject"});d.onclick=()=>void this.plugin.runtime.resolveApproval(s,n,"rejected").then(()=>this.render())}renderStatCard(t,s,n,r,i,o){let l=t.createDiv({cls:"af-stat-card"}),c=l.createDiv({cls:"af-stat-label"});T(c,i,"af-stat-icon"),c.appendText(` ${s}`);let d=l.createDiv({cls:"af-stat-value"});d.appendText(n),r&&d.createSpan({cls:"af-stat-value-suffix",text:r}),l.createDiv({cls:"af-stat-sub",text:o})}renderActivityTimeline(t,s){let n=t.createDiv({cls:"af-section-card"}),i=n.createDiv({cls:"af-section-header"}).createDiv({cls:"af-section-title"});T(i,"inbox"),i.appendText(" Recent Activity");let o=n.createDiv({cls:"af-timeline"}),l=s.slice(0,8);if(l.length===0){this.renderEmptyState(o,"inbox","No runs yet","Run an agent to see activity here");return}for(let c of l)this.renderTimelineItem(o,c)}renderTimelineItem(t,s){let n=t.createDiv({cls:"af-timeline-item"}),r=this.statusToTimelineClass(s.status),i=n.createDiv({cls:`af-tl-icon ${r}`});(0,Z.setIcon)(i,this.statusToIconName(s.status));let o=n.createDiv({cls:"af-tl-body"}),l=o.createDiv({cls:"af-tl-title"});l.createSpan({cls:"af-agent-tag",text:s.agent}),l.appendText(` ${s.task}`),o.createDiv({cls:"af-tl-desc",text:At(s.output,100)});let c=o.createDiv({cls:"af-tl-meta"}),d=c.createSpan();if(T(d,"clock","af-meta-icon"),d.appendText(` ${this.formatStarted(s.started)} \xB7 ${this.formatDuration(s.durationSeconds)}`),s.tokensUsed){let u=c.createSpan();T(u,"zap","af-meta-icon"),u.appendText(` ${s.tokensUsed.toLocaleString()} tokens`)}n.onclick=()=>this.openSlideover(s)}renderFleetStatusPanel(t,s){let n=t.createDiv({cls:"af-section-card"}),r=n.createDiv({cls:"af-section-header"}),i=r.createDiv({cls:"af-section-title"});T(i,"bot"),i.appendText(" Fleet Status");let l=r.createDiv({cls:"af-section-actions"}).createEl("button",{cls:"af-btn-sm primary"});T(l,"plus","af-btn-icon"),l.appendText(" New Agent"),l.onclick=()=>void this.plugin.createAgentTemplate();let c=n.createDiv({cls:"af-agent-mini-list"});if(s.agents.length===0){this.renderEmptyState(c,"bot","No agents yet","Create your first agent to get started",{label:"New Agent",onClick:()=>void this.plugin.createAgentTemplate()});return}for(let u of s.agents)this.renderAgentMini(c,u,s.tasks);let d=s.agents.filter(u=>u.enabled);if(d.length>0){let u=n.createDiv({cls:"af-quick-run"}),h=u.createDiv({cls:"af-quick-run-label"});T(h,"zap","af-meta-icon"),h.appendText(" Quick Run");let p=u.createDiv({cls:"af-quick-run-row"}),m=p.createEl("select",{cls:"af-select"});for(let g of d)m.createEl("option",{text:g.name,attr:{value:g.name}});let f=p.createEl("button",{cls:"af-btn-sm primary"});T(f,"play","af-btn-icon"),f.appendText(" Run"),f.onclick=()=>void this.plugin.runAgentPrompt(m.value)}}renderAgentMini(t,s,n){let r=this.plugin.runtime.getAgentState(s.name),i=n.filter(h=>h.agent===s.name),o=this.healthToClass(r.status),l=t.createDiv({cls:"af-agent-mini"}),c=l.createDiv({cls:`af-agent-avatar ${o}`});s.avatar?.trim()?this.renderAgentAvatar(c,s):c.setText(this.getInitials(s.name));let d=l.createDiv({cls:"af-agent-info"});d.createDiv({cls:"af-agent-name",text:s.name});let u="";if(r.status==="running")u=`Running now \xB7 ${i.length} task${i.length!==1?"s":""}`;else if(!s.enabled)u=`Disabled \xB7 ${i.length} task${i.length!==1?"s":""} paused`;else{let h=i.map(p=>p.nextRun).filter(Boolean).sort()[0];u=h?`Next: ${this.formatNextRun(h)} \xB7 ${i.length} task${i.length!==1?"s":""}`:`${i.length} task${i.length!==1?"s":""}`}d.createDiv({cls:"af-agent-desc",text:u}),l.createDiv({cls:`af-agent-status-dot ${o}`}),l.onclick=()=>this.navigate("agent-detail",s.name)}renderAgentStat(t,s,n){let r=t.createDiv({cls:"af-agent-stat"});r.createSpan({cls:"af-agent-stat-value",text:s}),r.createSpan({cls:"af-agent-stat-label",text:n})}renderConfigRow(t,s,n){let r=t.createDiv({cls:"af-detail-row"});r.createSpan({cls:"af-detail-label",text:s}),r.createSpan({cls:"af-detail-value af-mono",text:n})}timeAgo(t){let s=Math.round((Date.now()-t.getTime())/1e3);if(s<60)return"just now";let n=Math.round(s/60);if(n<60)return`${n}m ago`;let r=Math.round(n/60);return r<24?`${r}h ago`:`${Math.round(r/24)}d ago`}timeUntil(t){let s=Math.round((t.getTime()-Date.now())/1e3);if(s<60)return"< 1m";let n=Math.round(s/60);if(n<60)return`in ${n}m`;let r=Math.round(n/60);return r<24?`in ${r}h`:`in ${Math.round(r/24)}d`}renderKanbanPage(t){let s=t.createDiv({cls:"af-kanban-page"}),n=this.plugin.runtime.getSnapshot(),r=this.plugin.runtime.getRecentRuns(),i=s.createDiv({cls:"af-kanban-toolbar"});i.createDiv({cls:"af-page-title",text:"Tasks Board"}),i.createDiv({cls:"af-toolbar-spacer"});let o=i.createEl("button",{cls:"af-btn-sm primary"});T(o,"plus","af-btn-icon"),o.appendText(" New Task"),o.onclick=()=>this.navigate("create-task");let l=s.createDiv({cls:"af-kanban-board"}),c=[],d=[],u=[],h=[],p=[],m=new Set;for(let y of n.agents){let v=this.plugin.runtime.getAgentState(y.name);v.status==="running"&&v.currentTaskId&&m.add(v.currentTaskId)}let f=this.toLocalDateStr(new Date);for(let y of r)y.status==="success"&&this.runToLocalDate(y.started)===f?h.push(y):(y.status==="failure"||y.status==="timeout"||y.status==="cancelled")&&this.runToLocalDate(y.started)===f&&p.push(y);let g=new Set(h.map(y=>y.task)),w=new Set(p.map(y=>y.task));for(let y of n.tasks){let v=g.has(y.taskId)||w.has(y.taskId)||y.lastRun&&this.runToLocalDate(y.lastRun)===f;if(m.has(y.taskId))u.push(y);else{if(v&&!y.schedule)continue;y.schedule&&y.enabled?d.push(y):c.push(y)}}this.renderKanbanColumn(l,"Backlog","inbox",c.length,y=>{for(let v of c)this.renderKanbanTaskCard(y,v,n,!0)},"backlog"),this.renderKanbanColumn(l,"Scheduled","clock",d.length,y=>{for(let v of d)this.renderKanbanTaskCard(y,v,n,!0)},"scheduled"),this.renderKanbanColumn(l,"Running","loader-2",u.length,y=>{for(let v of u)this.renderKanbanRunningCard(y,v)},"running",!1,"running"),this.renderKanbanColumn(l,"Done","check-circle-2",h.length,y=>{for(let v of h.slice(0,10))this.renderKanbanCompletedCard(y,v)},"completed"),this.renderKanbanColumn(l,"Failed","x-circle",p.length,y=>{for(let v of p)this.renderKanbanFailedCard(y,v)},"failed",!1,"failed")}renderKanbanColumn(t,s,n,r,i,o,l=!1,c){let d=t.createDiv({cls:`af-kanban-column${c?` af-kanban-${c}`:""}`});(o==="backlog"||o==="scheduled")&&Kl(d,m=>this.handleTaskDrop(m,o));let h=d.createDiv({cls:"af-kanban-col-header"}).createDiv({cls:"af-kanban-col-title"});T(h,n),h.appendText(` ${s} `),h.createSpan({cls:"af-kanban-col-count",text:String(r)});let p=d.createDiv({cls:"af-kanban-col-body"});if(i(p),r===0&&p.createDiv({cls:"af-kanban-empty",text:"No items"}),l){let f=d.createDiv({cls:"af-kanban-col-add"}).createEl("button");T(f,"plus","af-btn-icon"),f.appendText(" Add task"),f.onclick=()=>{this.navigate("create-task")}}}handleTaskDrop(t,s){let n=this.plugin.runtime.getSnapshot().tasks.find(r=>r.taskId===t);if(n){if(s==="backlog")this.setTaskEnabled(n,!1).then(()=>{new Z.Notice(`Task "${t}" moved to backlog (disabled)`)});else if(s==="scheduled"){if(!n.schedule&&!n.runAt){new Z.Notice(`Task "${t}" needs a schedule. Open task details to set one.`),this.navigate("task-detail",t);return}this.setTaskEnabled(n,!0).then(()=>{new Z.Notice(`Task "${t}" moved to scheduled (enabled)`)})}}}async setTaskEnabled(t,s){let n=this.plugin.app.vault.getAbstractFileByPath(t.filePath);if(!n||!(n instanceof Z.TFile))return;let r=await this.plugin.app.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);i.enabled=s,await this.plugin.app.vault.modify(n,U(i,o)),await this.plugin.refreshFromVault()}renderKanbanTaskCard(t,s,n,r){let i=t.createDiv({cls:`af-kanban-card af-priority-${s.priority}`});if(r){Yl(i,s.taskId);let f=i.createDiv({cls:"af-kanban-card-grip"});(0,Z.setIcon)(f,"grip-vertical")}let o=i.createDiv({cls:"af-kanban-card-header"});o.createDiv({cls:"af-kanban-card-title",text:s.taskId});let c=(n.agents.find(f=>f.name===s.agent)?.enabled??!1)&&s.enabled,d=o.createSpan({cls:`af-kanban-card-status ${c?"active":"inactive"}`});d.title=c?"Active":"Inactive";let u=i.createDiv({cls:"af-kanban-card-agent"}),h=u.createSpan({cls:"af-kanban-card-agent-icon"});(0,Z.setIcon)(h,"bot"),u.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let m=i.createDiv({cls:"af-kanban-card-footer"}).createSpan({cls:"af-kanban-card-schedule"});c?s.schedule?(T(m,"refresh-cw","af-meta-icon"),m.appendText(` ${this.humanizeCron(s.schedule)}`)):m.appendText(s.runAt??"Manual"):(T(m,"pause","af-meta-icon"),m.appendText(" Paused")),i.onclick=()=>this.navigate("task-detail",s.taskId)}renderKanbanRunningCard(t,s){let n=t.createDiv({cls:"af-kanban-card af-kanban-card-running"});n.createDiv({cls:"af-kanban-card-title",text:s.taskId});let r=n.createDiv({cls:"af-kanban-card-agent"}),i=r.createSpan({cls:"af-kanban-card-agent-icon"});(0,Z.setIcon)(i,"bot"),r.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let l=this.plugin.runtime.getSnapshot().agents.find(b=>b.name===s.agent)?.timeout??300,c=this.plugin.runtime.getAgentState(s.agent),d=c.runStarted?new Date(c.runStarted).getTime():Date.now(),p=n.createDiv({cls:"af-kanban-progress"}).createDiv({cls:"af-kanban-progress-track"}).createDiv({cls:"af-kanban-progress-bar af-kanban-progress-bar-real"}),m=(Date.now()-d)/1e3,f=Math.min(95,m/l*100);p.setCssStyles({width:`${f}%`});let g=n.createDiv({cls:"af-kanban-card-footer"}),w=g.createSpan({cls:"af-kanban-card-schedule"});T(w,"loader-2","af-meta-icon");let y=Math.round(m),v=w.createSpan({text:` ${y}s / ${l}s`}),k=g.createEl("button",{cls:"af-kanban-stop-btn"});(0,Z.setIcon)(k,"square"),k.title="Stop task",k.onclick=b=>{b.stopPropagation(),this.plugin.runtime.abortAgentRun(s.agent),new Z.Notice(`Stopped task "${s.taskId}"`)},this.runningCardTickers.push(()=>{let b=(Date.now()-d)/1e3,P=Math.min(95,b/l*100);p.setCssStyles({width:`${P}%`}),v.setText(` ${Math.round(b)}s / ${l}s`)}),this.runningCardInterval===void 0&&(this.runningCardInterval=window.setInterval(()=>{for(let b of this.runningCardTickers)b()},1e3))}renderKanbanCompletedCard(t,s){let n=t.createDiv({cls:"af-kanban-card"});n.createDiv({cls:"af-kanban-card-title",text:s.task});let r=n.createDiv({cls:"af-kanban-card-agent"}),i=r.createSpan({cls:"af-kanban-card-agent-icon"});(0,Z.setIcon)(i,"bot"),r.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let l=n.createDiv({cls:"af-kanban-card-footer"}).createSpan({cls:"af-kanban-card-schedule"});T(l,"check-circle-2","af-meta-icon"),l.appendText(` ${this.formatStarted(s.started)} \xB7 ${this.formatDuration(s.durationSeconds)}`),n.onclick=()=>this.openSlideover(s)}renderKanbanFailedCard(t,s){let n=s.status==="cancelled",r=t.createDiv({cls:`af-kanban-card ${n?"af-kanban-card-cancelled":"af-kanban-card-failed"}`});r.createDiv({cls:"af-kanban-card-title",text:s.task});let i=r.createDiv({cls:"af-kanban-card-agent"}),o=i.createSpan({cls:"af-kanban-card-agent-icon"});(0,Z.setIcon)(o,"bot"),i.createSpan({cls:"af-kanban-card-agent-name",text:s.agent});let l=n?`Stopped after ${s.durationSeconds}s`:s.status==="timeout"?`Timeout after ${s.durationSeconds}s`:At(s.output,60),c=r.createDiv({cls:"af-kanban-card-error"});T(c,n?"square":"alert-triangle","af-meta-icon"),c.appendText(` ${l}`);let d=r.createDiv({cls:"af-kanban-card-footer"}),u=d.createSpan({cls:"af-kanban-card-schedule"});if(T(u,n?"square":"x-circle","af-meta-icon"),u.appendText(` ${this.formatStarted(s.started)}`),!n){let h=d.createEl("button",{cls:"af-btn-sm"});T(h,"refresh-cw","af-btn-icon"),h.appendText(" Retry"),h.onclick=p=>{p.stopPropagation(),this.plugin.runAgentPrompt(s.agent)}}r.onclick=()=>this.openSlideover(s)}renderCreateChannelPage(t){let s=t.createDiv({cls:"af-create-agent-page"});$r(s,this.channelFormDeps())}renderEditChannelPage(t){let s=t.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"radio","No channel selected","");return}let r=this.plugin.runtime.getSnapshot().channels.find(i=>i.name===n);if(!r){this.renderEmptyState(s,"radio","Channel not found",`Channel "${n}" was not found`);return}$r(s,this.channelFormDeps(),r)}channelFormDeps(){return{plugin:this.plugin,navigate:(t,s)=>this.navigate(t,s),addTooltip:(t,s)=>this.addTooltip(t,s),createFormField:(t,s,n,r,i,o)=>this.createFormField(t,s,n,r,i,o),markSubmitBusy:(t,s,n,r)=>this.markSubmitBusy(t,s,n,r)}}baseFormDeps(){return{plugin:this.plugin,addTooltip:(t,s)=>this.addTooltip(t,s),createFormField:(t,s,n,r,i,o)=>this.createFormField(t,s,n,r,i,o),markSubmitBusy:(t,s,n,r)=>this.markSubmitBusy(t,s,n,r)}}agentFormDeps(){return{...this.baseFormDeps(),navigate:(t,s)=>this.navigate(t,s),renderAgentAvatar:(t,s)=>this.renderAgentAvatar(t,s)}}taskFormDeps(){return{...this.baseFormDeps(),navigate:(t,s)=>this.navigate(t,s)}}skillFormDeps(){return{...this.baseFormDeps(),navigate:t=>this.navigate(t)}}mcpFormDeps(){return{...this.baseFormDeps(),navigate:t=>this.navigate(t)}}basePageDeps(){return{plugin:this.plugin,renderEmptyState:(t,s,n,r,i)=>this.renderEmptyState(t,s,n,r,i)}}agentsPageDeps(){return{...this.basePageDeps(),navigate:(t,s)=>this.navigate(t,s),healthToClass:t=>this.healthToClass(t),renderAgentAvatar:(t,s)=>this.renderAgentAvatar(t,s),renderAgentStat:(t,s,n)=>this.renderAgentStat(t,s,n),formatTokenCount:t=>qr(t),cronToHuman:t=>Rc(t),timeUntil:t=>this.timeUntil(t)}}runsPageDeps(){return{...this.basePageDeps(),navigate:(t,s)=>this.navigate(t,s),openSlideover:t=>this.openSlideover(t),formatStarted:t=>this.formatStarted(t),formatDuration:t=>this.formatDuration(t),statusToBadgeClass:t=>this.statusToBadgeClass(t),statusToIconName:t=>this.statusToIconName(t),statusToBadgeText:t=>this.statusToBadgeText(t)}}skillsPageDeps(){return{...this.basePageDeps(),navigate:(t,s)=>this.navigate(t,s)}}approvalsPageDeps(){return{...this.basePageDeps(),formatStarted:t=>this.formatStarted(t),rerender:()=>this.render()}}channelsPageDeps(){return{...this.basePageDeps(),navigate:(t,s)=>this.navigate(t,s),renderAgentStat:(t,s,n)=>this.renderAgentStat(t,s,n)}}mcpPageDeps(){return{...this.basePageDeps(),navigate:t=>this.navigate(t),renderDetailRow:(t,s,n)=>this.renderDetailRow(t,s,n),rerender:()=>this.render(),contentEl:this.contentEl,mcpProbeCache:this.mcpProbeCache,authenticatingServers:this.authenticatingServers}}agentDetailPageDeps(){return{...this.basePageDeps(),navigate:(t,s)=>this.navigate(t,s),healthToClass:t=>this.healthToClass(t),renderAgentAvatar:(t,s)=>this.renderAgentAvatar(t,s),renderStatCard:(t,s,n,r,i,o)=>this.renderStatCard(t,s,n,r,i,o),combinedTotals:(t,s)=>this.combinedTotals(t,s),renderTimelineItem:(t,s)=>this.renderTimelineItem(t,s),renderConfigRow:(t,s,n)=>this.renderConfigRow(t,s,n),statusToTimelineClass:t=>this.statusToTimelineClass(t),statusToIconName:t=>this.statusToIconName(t),statusToBadgeClass:t=>this.statusToBadgeClass(t),statusToBadgeText:t=>this.statusToBadgeText(t),formatStarted:t=>this.formatStarted(t),formatDuration:t=>this.formatDuration(t),formatTokenCount:t=>qr(t),cronToHuman:t=>Rc(t),timeUntil:t=>this.timeUntil(t),openSlideover:t=>this.openSlideover(t),openChatSlideover:t=>this.openChatSlideover(t),getDetailTab:()=>this.agentDetailTab,setDetailTab:t=>{this.agentDetailTab=t},rerender:()=>this.render()}}taskDetailPageDeps(){return{...this.basePageDeps(),navigate:(t,s)=>this.navigate(t,s),renderConfigRow:(t,s,n)=>this.renderConfigRow(t,s,n),renderTimelineItem:(t,s)=>this.renderTimelineItem(t,s),humanizeCron:t=>this.humanizeCron(t),formatStarted:t=>this.formatStarted(t)}}handleSearch(t,s){if(s.querySelector(".af-search-results")?.remove(),t.length<2)return;let n=t.toLowerCase(),r=this.plugin.runtime.getSnapshot(),i=this.plugin.runtime.getRecentRuns(),o=[];for(let c of r.agents)(c.name.toLowerCase().includes(n)||(c.description?.toLowerCase().includes(n)??!1))&&o.push({label:`Agent: ${c.name}`,icon:"bot",action:()=>this.navigate("agent-detail",c.name)});for(let c of r.tasks)(c.taskId.toLowerCase().includes(n)||c.body.toLowerCase().includes(n))&&o.push({label:`Task: ${c.taskId}`,icon:"circle-dot",action:()=>this.navigate("task-detail",c.taskId)});for(let c of r.skills)c.name.toLowerCase().includes(n)&&o.push({label:`Skill: ${c.name}`,icon:"puzzle",action:()=>this.navigate("edit-skill",c.name)});for(let c of i.slice(0,20))c.output.toLowerCase().includes(n)&&o.push({label:`Run: ${c.agent} / ${c.task}`,icon:"scroll-text",action:()=>this.openSlideover(c)});let l=s.createDiv({cls:"af-search-results"});if(o.length===0){l.createDiv({cls:"af-search-result-empty",text:`No results for "${t}"`});return}for(let c of o.slice(0,10)){let d=l.createDiv({cls:"af-search-result-item"});T(d,c.icon,"af-search-result-icon"),d.createSpan({text:c.label}),d.onclick=()=>{l.remove(),c.action()}}o.length>10&&l.createDiv({cls:"af-search-result-footer",text:`Showing 10 of ${o.length} results`})}openChatSlideover(t){this.plugin.openChatView(t.name)}openSlideover(t){this.contentEl.querySelector(".af-slideover-overlay")?.remove();let s=this.contentEl.createDiv({cls:"af-slideover-overlay"}),n=s.createDiv({cls:"af-slideover"}),r=n.createDiv({cls:"af-slideover-header"});r.createDiv({cls:"af-slideover-title",text:"Run Details"});let i=r.createEl("button",{cls:"clickable-icon"});(0,Z.setIcon)(i,"cross"),i.onclick=()=>s.remove();let o=n.createDiv({cls:"af-slideover-body"}),l=o.createDiv({cls:"af-slideover-section"});l.createDiv({cls:"af-slideover-section-title",text:"METADATA"}),this.renderDetailRow(l,"Run ID",t.runId.slice(0,8)),this.renderDetailRow(l,"Agent",t.agent),this.renderDetailRow(l,"Task",t.task);let c=l.createDiv({cls:"af-detail-row"});c.createSpan({cls:"af-detail-label",text:"Status"});let u=c.createSpan({cls:"af-detail-value"}).createSpan({cls:`af-status-badge ${this.statusToBadgeClass(t.status)}`}),h=u.createSpan();(0,Z.setIcon)(h,this.statusToIconName(t.status)),u.appendText(` ${this.statusToBadgeText(t.status)}`),this.renderDetailRow(l,"Started",t.started),this.renderDetailRow(l,"Duration",this.formatDuration(t.durationSeconds)),this.renderDetailRow(l,"Tokens",t.tokensUsed?t.tokensUsed.toLocaleString():"\u2014");{let k={task:"from task override",agent:"from agent",settings:"from settings default","cli-default":"CLI default"},b=t.modelSource?` (${k[t.modelSource]??t.modelSource})`:"",P=t.concreteModel&&t.concreteModel!==t.model?` \u2192 ${t.concreteModel}`:"";this.renderDetailRow(l,"Model",`${t.model}${P}${b}`)}let p=5e4,m=k=>k.length>p?k.slice(0,p)+` -### list -...`,rows:"8"}});C.value=a.toolsBody,C.addEventListener("input",()=>{d.toolsBody=C.value});let E=u.createDiv({cls:"af-create-section"}),P=E.createDiv({cls:"af-create-section-header"}),N=P.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(N,"book-open");let M=P.createSpan({text:"References"});this.addTooltip(M,"Background docs, conventions, cheat sheets");let U=E.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"API docs, filter syntax, conventions...",rows:"6"}});U.value=a.referencesBody,U.addEventListener("input",()=>{d.referencesBody=U.value});let $=u.createDiv({cls:"af-create-section"}),Z=$.createDiv({cls:"af-create-section-header"}),de=Z.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(de,"message-circle");let me=Z.createSpan({text:"Examples"});this.addTooltip(me,"Example prompts and ideal outputs showing how to use this skill");let ge=$.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`## Example: List all tasks -...`,rows:"6"}});ge.value=a.examplesBody,ge.addEventListener("input",()=>{d.examplesBody=ge.value});let X=s.createDiv({cls:"af-create-footer"}),W=X.createEl("button",{cls:"af-btn-sm danger"});R(W,"trash-2","af-btn-icon"),W.appendText(" Delete"),W.onclick=async()=>{await this.plugin.repository.deleteSkill(a.name),new b.Notice(`Skill "${a.name}" deleted.`),await new Promise(G=>window.setTimeout(G,200)),await this.plugin.refreshFromVault(),this.navigate("skills")},X.createDiv({cls:"af-toolbar-spacer"});let K=X.createEl("button",{cls:"af-btn-sm",text:"Cancel"});K.onclick=()=>this.navigate("skills");let ne=X.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(ne,"check","af-btn-icon"),ne.appendText(" Save Changes"),ne.onclick=async()=>{let G=ee=>ee.split(",").map(Q=>Q.trim()).filter(Boolean);try{await this.plugin.repository.updateSkill(a.name,{description:d.description.trim(),tags:G(d.tags),body:d.body.trim(),toolsBody:d.toolsBody.trim(),referencesBody:d.referencesBody.trim(),examplesBody:d.examplesBody.trim()}),new b.Notice(`Skill "${a.name}" updated.`),await this.plugin.refreshFromVault(),this.navigate("skills")}catch(ee){let Q=ee instanceof Error?ee.message:String(ee);new b.Notice(`Failed to update skill: ${Q}`)}}}renderAgentMcpPicker(e,s){e.createDiv({cls:"af-form-hint",text:"Servers from the MCP Servers tab. Checked servers are available to this agent on any adapter (Claude or Codex). Leave all unchecked to grant every enabled server."});let n=this.plugin.repository.getMcpServers();if(n.length===0){let i=e.createDiv({cls:"af-form-hint"});i.appendText("No MCP servers registered yet. ");let o=i.createEl("a",{cls:"af-link",text:"Add one in the MCP Servers tab."});o.onclick=c=>{c.preventDefault(),this.navigate("mcp")};return}let a=e.createDiv({cls:"af-create-skills-grid"});for(let i of n){let o=a.createDiv({cls:"af-mcp-agent-item"}),c=o.createEl("input",{cls:"af-form-toggle",attr:{type:"checkbox"}});c.checked=s.has(i.name),c.addEventListener("change",()=>{c.checked?s.add(i.name):s.delete(i.name)});let h=o.createDiv({cls:"af-mcp-agent-label"}).createDiv({cls:"af-mcp-agent-name-row"}),d=h.createSpan({cls:`af-mcp-status-dot ${i.enabled?"idle":"disabled"}`});d.title=i.enabled?"enabled":"disabled",h.createSpan({cls:"af-create-skill-name",text:i.name}),h.createSpan({cls:"af-mcp-agent-tool-count",text:i.type}),i.enabled||h.createSpan({cls:"af-mcp-agent-tool-count af-muted",text:"disabled"})}}renderMcpPage(e){let s=e.createDiv({cls:"af-agents-page"}),n=s.createDiv({cls:"af-agents-toolbar"});n.createDiv({cls:"af-page-title",text:"MCP Servers"}),n.createDiv({cls:"af-toolbar-spacer"});let a=n.createEl("button",{cls:"af-btn-sm primary"});R(a,"plus","af-btn-icon"),a.appendText(" Add Server"),a.onclick=()=>this.navigate("add-mcp-server");let i=this.plugin.repository.getMcpServers();if(i.length===0){this.renderEmptyState(s,"plug","No MCP servers registered","Click 'Add Server' above to register one.");return}let o=s.createDiv({cls:"af-agents-grid"});for(let c of i)this.renderMcpCard(o,c)}mcpProbeCache=new Map;mcpHasToken(e){return this.plugin.mcpAuth.hasToken(e.name)}mcpNeedsAuth(e){return e.type!=="stdio"&&(e.auth==="oauth"||e.auth==="bearer")&&!this.mcpHasToken(e)}renderMcpCard(e,s){let n=e.createDiv({cls:`af-mcp-card${s.enabled?"":" af-mcp-card-disabled"}`}),a=this.mcpNeedsAuth(s),i=n.createDiv({cls:"af-agent-card-header"}),o=s.enabled?a?"pending":"idle":"disabled",c=i.createDiv({cls:`af-agent-card-avatar ${o}`});(0,b.setIcon)(c,"plug");let l=i.createDiv({cls:"af-agent-card-titleblock"});l.createDiv({cls:"af-agent-card-name",text:s.name});let h=l.createDiv({cls:"af-agent-card-desc af-mcp-meta"});h.createSpan({cls:"af-mcp-type-badge",text:s.type}),s.source==="imported"&&h.createSpan({cls:"af-badge",text:"imported"});let d=i.createDiv({cls:`af-agent-card-toggle${s.enabled?" on":""}`});d.onclick=g=>{g.stopPropagation(),this.plugin.repository.setMcpServerEnabled(s.name,!s.enabled).then(async()=>{await this.plugin.refreshFromVault(),this.render()})};let u=n.createDiv({cls:`af-mcp-status-badge ${s.enabled?a?"needs-auth":"connected":"disabled"}`}),p=u.createSpan();if(s.enabled?a?((0,b.setIcon)(p,"alert-circle"),u.createSpan({text:" Needs auth"})):((0,b.setIcon)(p,"check-circle"),u.createSpan({text:s.type==="stdio"?" Enabled":" Authenticated"})):((0,b.setIcon)(p,"pause"),u.createSpan({text:" Disabled"})),s.description){let g=this.truncateDescription(s.description,120);n.createDiv({cls:"af-mcp-description",text:g})}let m=s.url??s.command??"";m&&n.createDiv({cls:"af-mcp-command",text:Wt(m,60)});let f=this.mcpProbeCache.get(s.name);if(f&&f.length>0){let g=n.createDiv({cls:"af-mcp-tool-footer"}),y=g.createDiv({cls:"af-mcp-tool-count"}),v=y.createSpan();(0,b.setIcon)(v,"wrench"),y.createSpan({text:` ${f.length} tools`});let k=g.createDiv({cls:"af-mcp-tool-chips"});for(let w of f.slice(0,4))k.createSpan({cls:"af-mcp-tool-chip",text:w.name});f.length>4&&k.createSpan({cls:"af-mcp-tool-chip af-mcp-tool-chip-more",text:`+${f.length-4}`})}if(this.authenticatingServers.has(s.name)){let y=n.createDiv({cls:"af-mcp-auth-row"}).createEl("button",{cls:"af-btn-sm primary",attr:{disabled:"true"}}),v=y.createSpan({cls:"af-spin"});(0,b.setIcon)(v,"loader-2"),y.appendText(" Authenticating\u2026")}else if(s.enabled&&a&&s.auth==="oauth"){let y=n.createDiv({cls:"af-mcp-auth-row"}).createEl("button",{cls:"af-btn-sm primary"}),v=y.createSpan();(0,b.setIcon)(v,"key"),y.appendText(" Authenticate"),y.onclick=k=>{k.stopPropagation(),this.authenticateMcpServer(s)}}n.onclick=()=>this.openMcpDetailSlideover(s)}async authenticateMcpServer(e){if(!e.url){new b.Notice("No URL found for this server \u2014 can't authenticate.");return}this.authenticatingServers.add(e.name),this.render(),new b.Notice(`Authenticating ${e.name}\u2026 Complete authorization in your browser.`,1e4);try{let s=e.type==="sse"?"sse":"http";await this.plugin.mcpManager.authenticateServer(e.name,e.url,s),new b.Notice(`${e.name} authenticated successfully!`)}catch(s){let n=s instanceof Error?s.message:String(s);new b.Notice(`Authentication failed: ${n}`,8e3)}finally{this.authenticatingServers.delete(e.name),this.render()}}async probeMcpServer(e){try{let s=await this.plugin.mcpManager.probeServer(e);this.mcpProbeCache.set(e.name,s),s.length===0&&new b.Notice(`No tools discovered for ${e.name}.`)}catch(s){let n=s instanceof Error?s.message:String(s);new b.Notice(`Probe failed: ${n}`)}}truncateDescription(e,s){let n=ye(e)[0]??e,a=n.split(/(?<=[.!?])\s/)[0]??n,i=a.lengths.remove(),s.onclick=y=>{y.target===s&&s.remove()};let o=n.createDiv({cls:"af-slideover-body"});if(e.description){let y=o.createDiv({cls:"af-slideover-section"});y.createDiv({cls:"af-slideover-section-title",text:"DESCRIPTION"}),y.createDiv({cls:"af-mcp-detail-description",text:e.description})}let c=o.createDiv({cls:"af-slideover-section"});c.createDiv({cls:"af-slideover-section-title",text:"SERVER INFO"}),this.renderDetailRow(c,"Name",e.name),this.renderDetailRow(c,"Transport",e.type),this.renderDetailRow(c,"Enabled",e.enabled?"yes":"no"),e.type!=="stdio"&&(this.renderDetailRow(c,"Auth",e.auth??"none"),this.renderDetailRow(c,"Authenticated",this.mcpHasToken(e)?"yes":"no")),e.source&&this.renderDetailRow(c,"Source",e.source),e.url&&this.renderDetailRow(c,"URL",e.url),e.command&&this.renderDetailRow(c,"Command",e.command),e.args&&e.args.length>0&&this.renderDetailRow(c,"Args",e.args.join(" "));let l=this.mcpProbeCache.get(e.name)??[],h=o.createDiv({cls:"af-slideover-section"});h.createDiv({cls:"af-slideover-section-title"}).setText(`TOOLS (${l.length})`);let u=h.createEl("button",{cls:"af-btn-sm"}),p=u.createSpan();if((0,b.setIcon)(p,"wrench"),u.appendText(" Probe tools"),u.onclick=async()=>{u.disabled=!0,u.setText(" Probing\u2026"),await this.probeMcpServer(e),s.remove(),this.openMcpDetailSlideover(e)},l.length>0)for(let y of l){let v=h.createDiv({cls:"af-mcp-tool-detail"}),k=v.createDiv({cls:"af-mcp-tool-detail-header"}),w=k.createSpan({cls:"af-mcp-tool-detail-name"}),S=w.createSpan();if((0,b.setIcon)(S,"wrench"),w.createSpan({text:` ${y.name}`}),y.inputSchema){let T=y.inputSchema.required??[];T.length>0&&k.createSpan({cls:"af-mcp-tool-param-count",text:`${T.length} param${T.length!==1?"s":""}`})}if(y.description){let T=ye(y.description).filter(O=>O.trim()),_=T.slice(0,2).join(" ").trim();if(T.length>2){let O=v.createEl("details",{cls:"af-mcp-tool-detail-desc"});O.createEl("summary",{text:this.truncateDescription(_,200)}),O.createDiv({cls:"af-mcp-tool-detail-full",text:y.description})}else v.createDiv({cls:"af-mcp-tool-detail-desc",text:_})}if(y.inputSchema){let T=y.inputSchema.properties,_=new Set(y.inputSchema.required??[]);if(T&&Object.keys(T).length>0){let D=v.createDiv({cls:"af-mcp-tool-params"});for(let[O,C]of Object.entries(T)){let E=D.createDiv({cls:"af-mcp-tool-param"});E.createSpan({cls:"af-mcp-tool-param-name",text:O}),C.type&&E.createSpan({cls:"af-mcp-tool-param-type",text:C.type}),_.has(O)&&E.createSpan({cls:"af-mcp-tool-param-required",text:"required"}),C.description&&E.createSpan({cls:"af-mcp-tool-param-desc",text:Wt(C.description,80)})}}}}else h.createDiv({cls:"af-form-hint",text:'Click "Probe tools" to discover the tools this server exposes.'});let m=o.createDiv({cls:"af-slideover-section"});if(m.createDiv({cls:"af-slideover-section-title",text:"ACTIONS"}),e.enabled&&e.url&&e.auth==="oauth"&&!this.mcpHasToken(e)){let y=m.createEl("button",{cls:"af-btn-sm primary"}),v=y.createSpan();(0,b.setIcon)(v,"key"),y.appendText(" Authenticate"),y.onclick=()=>{s.remove(),this.authenticateMcpServer(e)}}let f=m.createEl("button",{cls:"af-btn-sm danger"}),g=f.createSpan();(0,b.setIcon)(g,"trash-2"),f.appendText(" Remove Server"),f.onclick=async()=>{try{await this.plugin.repository.deleteMcpServer(e.name),this.plugin.mcpAuth.removeToken(e.name),this.mcpProbeCache.delete(e.name),new b.Notice(`Server "${e.name}" removed.`),s.remove(),await this.plugin.refreshFromVault(),this.render()}catch(y){let v=y instanceof Error?y.message:String(y);new b.Notice(`Failed to remove server: ${v}`)}}}renderAddMcpServerPage(e){let s=e.createDiv({cls:"af-create-agent-page"}),n=s.createDiv({cls:"af-detail-header"}),a=n.createDiv({cls:"af-detail-header-left"}),i=a.createDiv({cls:"af-agent-card-avatar idle"});(0,b.setIcon)(i,"plus");let o=a.createDiv();o.createDiv({cls:"af-detail-header-name",text:"Add MCP Server"}),o.createDiv({cls:"af-detail-header-desc",text:"Register a new MCP server for agents to use"});let c=n.createDiv({cls:"af-detail-header-actions"}),l={name:"",transport:"stdio",description:"",command:"",args:"",envVars:"",url:"",headers:"",auth:"none",bearerToken:""},h=s.createDiv({cls:"af-create-form"}),d=h.createDiv({cls:"af-create-section"}),u=d.createDiv({cls:"af-create-section-header"}),p=u.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(p,"plug"),u.createSpan({text:"Server Details"}),this.createFormField(d,"Name","my-server","Unique name for this MCP server",W=>{l.name=W});let m=d.createDiv({cls:"af-form-row"}),f=m.createDiv({cls:"af-form-label"});f.setText("Transport"),this.addTooltip(f,"stdio: local process, http/sse: remote server");let g=m.createEl("select",{cls:"af-form-select"});g.createEl("option",{text:"stdio",attr:{value:"stdio"}}),g.createEl("option",{text:"http",attr:{value:"http"}}),g.createEl("option",{text:"sse",attr:{value:"sse"}}),this.createFormField(d,"Description","What this server does (optional)","Shown on the server card",W=>{l.description=W});let y=h.createDiv({cls:"af-create-section"}),v=y.createDiv({cls:"af-create-section-header"}),k=v.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(k,"terminal"),v.createSpan({text:"Process Configuration"}),this.createFormField(y,"Command","npx @anthropic-ai/mcp-server-memory","The command to run",W=>{l.command=W}),this.createFormField(y,"Arguments","--port 3000","Space-separated arguments (optional)",W=>{l.args=W});let w=y.createDiv({cls:"af-form-label"});w.setText("Environment variables"),this.addTooltip(w,"One KEY=VALUE per line");let S=y.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:`API_KEY=sk-... -DEBUG=true`,rows:"3"}});S.addEventListener("input",()=>{l.envVars=S.value});let T=h.createDiv({cls:"af-create-section"}),_=T.createDiv({cls:"af-create-section-header"}),D=_.createSpan({cls:"af-create-section-icon"});(0,b.setIcon)(D,"globe"),_.createSpan({text:"Remote Server Configuration"}),this.createFormField(T,"URL","https://mcp.example.com/sse","Server endpoint URL",W=>{l.url=W});let O=T.createDiv({cls:"af-form-label"});O.setText("Custom headers"),this.addTooltip(O,"One Header: Value per line (optional, non-secret)");let C=T.createEl("textarea",{cls:"af-create-prompt-textarea",attr:{placeholder:"X-Custom-Header: value",rows:"2"}});C.addEventListener("input",()=>{l.headers=C.value});let E=T.createDiv({cls:"af-form-row"}),P=E.createDiv({cls:"af-form-label"});P.setText("Authentication"),this.addTooltip(P,"none, a static bearer token, or OAuth (authenticate after saving)");let N=E.createEl("select",{cls:"af-form-select"});N.createEl("option",{text:"None",attr:{value:"none"}}),N.createEl("option",{text:"Bearer token",attr:{value:"bearer"}}),N.createEl("option",{text:"OAuth",attr:{value:"oauth"}});let M=T.createDiv({cls:"af-form-row"}),U=M.createDiv({cls:"af-form-label"});U.setText("Bearer token"),this.addTooltip(U,"Stored securely in the OS keychain, never written to the vault");let $=M.createEl("input",{cls:"af-form-input",attr:{type:"password",placeholder:"sk-\u2026"}});$.addEventListener("input",()=>{l.bearerToken=$.value});let Z=()=>{M.setCssStyles({display:l.auth==="bearer"?"":"none"})};N.addEventListener("change",()=>{l.auth=N.value,Z()}),Z();let de=()=>{y.setCssStyles({display:l.transport==="stdio"?"":"none"}),T.setCssStyles({display:l.transport!=="stdio"?"":"none"})};g.addEventListener("change",()=>{l.transport=g.value,de()}),de();let me=s.createDiv({cls:"af-create-footer"}),ge=me.createEl("button",{cls:"af-btn-sm",text:"Cancel"});ge.onclick=()=>this.navigate("mcp");let X=me.createEl("button",{cls:"af-btn-sm primary af-create-submit"});R(X,"plus","af-btn-icon"),X.appendText(" Add Server"),X.onclick=async()=>{let W=l.name.trim();if(!W){new b.Notice("Server name is required.");return}if(l.transport==="stdio"){if(!l.command.trim()){new b.Notice("Command is required for stdio servers.");return}}else if(!l.url.trim()){new b.Notice("URL is required for HTTP/SSE servers.");return}let K={};if(l.envVars.trim())for(let Q of ye(l.envVars)){let ae=Q.trim();if(!ae)continue;let ve=ae.indexOf("=");if(ve<=0){new b.Notice(`Invalid env var: ${ae}`);return}K[ae.slice(0,ve)]=ae.slice(ve+1)}let ne={};if(l.headers.trim())for(let Q of ye(l.headers)){let ae=Q.trim();if(!ae)continue;let ve=ae.indexOf(":");if(ve<=0){new b.Notice(`Invalid header: ${ae}`);return}ne[ae.slice(0,ve).trim()]=ae.slice(ve+1).trim()}let G=l.args.trim()?l.args.trim().split(/\s+/):void 0;if(this.plugin.repository.getMcpServerByName(W)){new b.Notice(`An MCP server named "${W}" already exists.`);return}if(l.transport!=="stdio"&&l.auth==="bearer"&&!l.bearerToken.trim()){new b.Notice("Enter a bearer token, or choose a different auth method.");return}X.disabled=!0,X.setText("Adding...");let ee={name:W,type:l.transport,enabled:!0,source:"manual",status:"disconnected",scope:"user",tools:[],toolDetails:[]};l.transport==="stdio"?(ee.command=l.command.trim(),G&&(ee.args=G),Object.keys(K).length>0&&(ee.env=K)):(ee.url=l.url.trim(),Object.keys(ne).length>0&&(ee.headers=ne),ee.auth=l.auth);try{await this.plugin.repository.saveMcpServer(ee,l.description.trim()),l.transport!=="stdio"&&l.auth==="bearer"&&l.bearerToken.trim()&&this.plugin.mcpAuth.storeStaticToken(W,l.bearerToken.trim()),new b.Notice(`Server "${W}" added.`),await this.plugin.refreshFromVault(),this.navigate("mcp")}catch(Q){let ae=Q instanceof Error?Q.message:String(Q);new b.Notice(`Failed to add server: ${ae}`),X.disabled=!1,X.setText(""),R(X,"plus","af-btn-icon"),X.appendText(" Add Server")}}}createFormField(e,s,n,a,i,o){let c=e.createDiv({cls:"af-form-row"}),l=c.createDiv({cls:"af-form-label"});l.setText(s),a&&this.addTooltip(l,a);let h=c.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:n}});o!==void 0&&(h.value=o),h.addEventListener("input",()=>i(h.value))}addTooltip(e,s){let n=e.createSpan({cls:"af-form-tooltip"});(0,b.setIcon)(n,"info"),n.createSpan({cls:"af-tooltip-text",text:s})}};function Qo(r){switch(r){case"connected":return"idle";case"connecting":case"reconnecting":return"pending";case"needs-auth":case"error":return"error";case"stopped":case"disabled":default:return"disabled"}}function Ka(r){return r>=1e6?`${(r/1e6).toFixed(1)}M`:r>=1e4?`${Math.round(r/1e3)}K`:r>=1e3?`${(r/1e3).toFixed(1)}K`:String(r)}function xh(r){if(!r?.trim())return"not set";let t={"*/5 * * * *":"Every 5 minutes","*/10 * * * *":"Every 10 minutes","*/15 * * * *":"Every 15 minutes","*/30 * * * *":"Every 30 minutes","0 * * * *":"Every hour","0 */2 * * *":"Every 2 hours","0 */4 * * *":"Every 4 hours","0 */6 * * *":"Every 6 hours","0 */12 * * *":"Every 12 hours"};if(t[r])return t[r];let e=r.trim().split(/\s+/);if(e.length===5){let[s,n,a,,i]=e;if(a==="*"&&n&&s){let o=Number(n),c=Number(s);if(!isNaN(o)&&!isNaN(c)){let l=o>=12?"PM":"AM",d=`${o===0?12:o>12?o-12:o}:${String(c).padStart(2,"0")} ${l}`;return i==="*"?`Daily at ${d}`:i==="1-5"?`Weekdays at ${d}`:`${d} on days ${i}`}}}return r}function Sh(r){switch(r){case"connected":return"green";case"connecting":case"reconnecting":return"blue";case"needs-auth":case"error":return"red";case"stopped":case"disabled":default:return""}}var z=require("obsidian");function qn(r,t){return`${r}::${t}`}function tl(){return Math.random().toString(16).slice(2,10)}function sl(){return`New chat ${new Date().toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}`}function Ch(r){let t=new Date(r).getTime();if(!Number.isFinite(t))return"";let e=Date.now()-t,s=60*1e3,n=60*s,a=24*n;return es.id===this.selectedConversationId)?.name??""}async onOpen(){this.plugin.subscribeView(this),this.buildShell(),await this.render(),this.observeContainerWidth()}async onClose(){for(let{session:e}of this.sessions.values())e.abort();this.sessions.clear(),this.statsUnsub?.(),this.statsUnsub=null,this.activityUnsub?.(),this.activityUnsub=null,this.resizeObserver?.disconnect(),this.resizeObserver=null,this.plugin.unsubscribeView(this)}observeContainerWidth(){typeof ResizeObserver>"u"||(this.resizeObserver=new ResizeObserver(e=>{if(!this.userToggledCollapse)for(let s of e){let a=s.contentRect.widththis.toggleConvoPanel(),this.agentSelect=this.headerEl.createEl("select",{cls:"af-chat-view-agent-select"});let n=this.headerEl.createEl("button",{cls:"af-btn-sm af-chat-view-new-btn"});R(n,"plus","af-btn-icon"),n.appendText(" New Chat"),n.onclick=()=>void this.handleNewChat();let a=s.createDiv({cls:"af-chat-view-body"});this.convoPanelEl=a.createDiv({cls:"af-chat-convo-panel"}),this.convoPanelCollapsed&&this.convoPanelEl.addClass("collapsed");let i=a.createDiv({cls:"af-chat-main"});this.agentSelect.onchange=()=>{let h=this.agentSelect.value;h&&(this.selectedConversationId="",this.textarea.disabled=!1,this.textarea.placeholder="Message the agent\u2026 (Shift+Enter for newline)",this.switchToAgent(h))},this.messagesEl=i.createDiv({cls:"af-chat-messages"}),this.messagesInner=this.messagesEl.createDiv({cls:"af-chat-messages-inner"});let o=i.createDiv({cls:"af-chat-input-area"});this.pillsRow=o.createDiv({cls:"af-chat-pills-row"}),this.pillsRow.setCssStyles({display:"none"});let c=o.createDiv({cls:"af-chat-input-row"});this.attachStopBtn=c.createEl("button",{cls:"af-chat-attach-btn"}),R(this.attachStopBtn,"plus","af-btn-icon"),this.attachStopBtn.title="Attach active document",this.attachStopBtn.onclick=()=>{this.isInStopMode?this.handleStop():this.attachActiveDocument()},this.textarea=c.createEl("textarea",{cls:"af-chat-input",attr:{placeholder:"Message the agent\u2026 (Shift+Enter for newline)",rows:"1"}}),this.sendBtn=c.createEl("button",{cls:"af-chat-send-btn"}),R(this.sendBtn,"arrow-up","af-btn-icon"),this.sendBtn.setCssStyles({display:"none"});let l=()=>{this.textarea.setCssStyles({height:"auto"});let h=Math.min(this.textarea.scrollHeight,160);this.textarea.setCssStyles({height:`${h}px`}),this.textarea.setCssStyles({overflowY:this.textarea.scrollHeight>160?"auto":"hidden"}),this.sendBtn.setCssStyles({display:this.textarea.value.trim()?"flex":"none"})};this.textarea.addEventListener("input",l),this.textarea.addEventListener("focus",()=>{let h=this.getCurrentSession();h&&this.setStatsSource(h.session)}),this.sendBtn.onclick=()=>void this.handleSend(),this.textarea.onkeydown=h=>{h.key==="Enter"&&!h.shiftKey&&!h.isComposing&&(h.preventDefault(),this.handleSend())},this.textarea.addEventListener("paste",h=>{let d=h.clipboardData?.items;if(d)for(let u=0;u{h.preventDefault(),h.stopPropagation(),o.addClass("af-chat-input-dragover")}),o.addEventListener("dragleave",()=>{o.removeClass("af-chat-input-dragover")}),o.addEventListener("drop",h=>{h.preventDefault(),h.stopPropagation(),o.removeClass("af-chat-input-dragover");let d=h.dataTransfer?.files;if(d)for(let u=0;uthis.renderStats(s)))}renderStats(e){if(this.statsEl.empty(),!e||!e.concreteModel){this.statsEl.createSpan({cls:"af-chat-stats-muted",text:"\xA0"});return}let s=this.statsEl.createSpan({cls:"af-chat-stats-line"});if(s.createSpan({cls:"af-chat-stats-model",text:e.concreteModel}),e.contextWindow&&e.contextTokensUsed){let n=Math.min(100,Math.round(e.contextTokensUsed/e.contextWindow*100)),a=10,i=Math.min(a,Math.max(0,Math.round(n/100*a))),o="\u2593".repeat(i)+"\u2591".repeat(a-i),c=s.createSpan({cls:`af-chat-stats-ctx${n>=80?" warn":""}`});c.createSpan({cls:"af-chat-stats-bar",text:o}),c.createSpan({cls:"af-chat-stats-pct",text:`${n}%`}),c.title=`Context: ${is(e.contextTokensUsed)} / ${is(e.contextWindow)} tokens (${n}%) +--- +*Truncated (${(k.length/1024).toFixed(0)} KB total). Open the run note for full content.*`:k,f=!!(t.finalResult&&t.finalResult.trim()),g=t.output?.trim()??"",w=f&&g.length>0&&g!==t.finalResult.trim();if(f){let k=o.createDiv({cls:"af-slideover-section"});k.createDiv({cls:"af-slideover-section-title",text:"OUTPUT"});let b=k.createDiv({cls:"af-output-block af-compact-md"});if(Z.MarkdownRenderer.render(this.app,m(t.finalResult),b,"",this),w){let P=k.createEl("details",{cls:"af-run-transcript"}),A=P.createEl("summary");(0,Z.setIcon)(A.createSpan({cls:"af-run-transcript-icon"}),"file-text"),A.createSpan({text:"Show full transcript"}),A.createSpan({cls:"af-run-transcript-meta"}).setText(`${(g.length/1024).toFixed(1)} KB`);let E=P.createDiv({cls:"af-output-block af-compact-md af-run-transcript-body"});Z.MarkdownRenderer.render(this.app,m(g),E,"",this)}}else if(g){let k=o.createDiv({cls:"af-slideover-section"});k.createDiv({cls:"af-slideover-section-title",text:"OUTPUT"});let b=k.createDiv({cls:"af-output-block af-compact-md"});Z.MarkdownRenderer.render(this.app,m(g),b,"",this)}if(t.toolsUsed.length>0){let k=o.createDiv({cls:"af-slideover-section"});k.createDiv({cls:"af-slideover-section-title",text:"TOOLS USED"}),k.createDiv({cls:"af-output-block",text:t.toolsUsed.join(` +`)})}let y=o.createDiv({cls:"af-slideover-actions"});if(t.filePath){let k=y.createEl("button",{cls:"af-btn-sm"});T(k,"external-link","af-btn-icon"),k.appendText(" Open Run Note"),k.onclick=()=>void this.plugin.openPath(t.filePath)}let v=y.createEl("button",{cls:"af-btn-sm primary"});T(v,"refresh-cw","af-btn-icon"),v.appendText(" Re-run Task"),v.onclick=()=>void this.plugin.runAgentPrompt(t.agent),s.onclick=k=>{k.target===s&&s.remove()}}renderDetailRow(t,s,n){let r=t.createDiv({cls:"af-detail-row"});r.createSpan({cls:"af-detail-label",text:s}),r.createSpan({cls:"af-detail-value af-mono",text:n})}renderEmptyState(t,s,n,r,i){let o=t.createDiv({cls:"af-empty-state"}),l=o.createDiv({cls:"af-empty-icon"});if((0,Z.setIcon)(l,s),o.createDiv({cls:"af-empty-label",text:n}),r&&o.createDiv({cls:"af-empty-sublabel",text:r}),i){let c=o.createEl("button",{cls:"af-btn-sm primary af-empty-action"});T(c,"plus","af-btn-icon"),c.appendText(` ${i.label}`),c.onclick=()=>i.onClick()}}markSubmitBusy(t,s,n,r){return t.disabled=!0,t.setText(s),()=>{t.disabled=!1,t.setText(""),T(t,n,"af-btn-icon"),t.appendText(` ${r}`)}}healthToClass(t){switch(t){case"running":return"running";case"error":return"error";case"pending":return"pending";case"disabled":return"disabled";default:return"idle"}}statusToTimelineClass(t){switch(t){case"success":return"success";case"failure":case"timeout":return"error";case"cancelled":return"warning";case"pending_approval":return"pending";default:return"running"}}statusToIconName(t){switch(t){case"success":return"check-circle-2";case"failure":return"x-circle";case"timeout":return"clock";case"pending_approval":return"shield-check";case"cancelled":return"square";default:return"loader-2"}}statusToBadgeClass(t){switch(t){case"success":return"success";case"failure":return"failure";case"timeout":return"timeout";case"pending_approval":return"pending";case"cancelled":return"cancelled";default:return"running"}}statusToBadgeText(t){switch(t){case"success":return"Success";case"failure":return"Failed";case"timeout":return"Timeout";case"pending_approval":return"Pending";case"cancelled":return"Cancelled";case"interrupted":return"Interrupted";default:return t}}formatDuration(t){if(t<60)return`${t}s`;let s=Math.floor(t/60),n=t%60;return n>0?`${s}m ${n}s`:`${s}m`}formatStarted(t){try{let s=new Date(t),n=new Date;if(s.toDateString()===n.toDateString())return s.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});let r=new Date(n);return r.setDate(r.getDate()-1),s.toDateString()===r.toDateString()?`Yesterday ${s.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`:s.toLocaleDateString([],{month:"short",day:"numeric"})+` ${s.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}catch{return t}}formatNextRun(t){try{let s=new Date(t),n=new Date,r=s.getTime()-n.getTime();if(r<0)return"overdue";let i=Math.round(r/6e4);if(i<60)return`${i}m`;let o=Math.round(i/60);return o<24?`${o}h`:s.toLocaleDateString([],{month:"short",day:"numeric"})}catch{return t}}getNextTaskLabel(t){let n=t.map(i=>i.nextRun).filter(Boolean).sort()[0];return n?`${t.find(i=>i.nextRun===n)?.agent??"unknown"} in ${this.formatNextRun(n)}`:"none"}getInitials(t){return t.split("-").map(s=>s[0]?.toUpperCase()??"").slice(0,2).join("")}renderAgentAvatar(t,s){let n=s.avatar?.trim();if(!n){(0,Z.setIcon)(t,"bot");return}/^[a-z][a-z0-9-]*$/.test(n)?(0,Z.setIcon)(t,n):t.setText(n)}humanizeCron(t){let s={"*/5 * * * *":"Every 5 minutes","*/10 * * * *":"Every 10 minutes","*/15 * * * *":"Every 15 minutes","*/30 * * * *":"Every 30 minutes","0 * * * *":"Every hour","0 */2 * * *":"Every 2 hours"};if(s[t])return s[t];let n=t.toLowerCase().trim();if(n.startsWith("every ")||n.startsWith("daily ")||n==="daily")return t;if(n.startsWith("hourly"))return"Every hour";if(n.startsWith("weekdays")||n.startsWith("weekly")||n.startsWith("monthly"))return t;let r=t.trim().split(/\s+/);if(r.length!==5)return t;let[i,o,l,,c]=r,d=(p,m)=>{let f=parseInt(p??"0",10),g=parseInt(m??"0",10),w=f>=12?"PM":"AM",y=f===0?12:f>12?f-12:f;return g===0?`${y} ${w}`:`${y}:${String(g).padStart(2,"0")} ${w}`},u=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],h=p=>p==="1-5"?"weekdays":p==="0,6"?"weekends":p.split(",").map(f=>parseInt(f,10)).map(f=>u[f]??f).join(", ");return o==="*"&&l==="*"&&c==="*"?i==="*"?"Every minute":`Every hour at :${String(i).padStart(2,"0")}`:l==="*"&&c==="*"&&o!=="*"?`Daily at ${d(o??"0",i??"0")}`:l==="*"&&c==="1-5"&&o!=="*"?`Weekdays at ${d(o??"0",i??"0")}`:l==="*"&&c!=="*"&&o!=="*"?`${h(c??"1")} at ${d(o??"0",i??"0")}`:c==="*"&&l!=="*"&&o!=="*"?`Monthly on the ${l} at ${d(o??"0",i??"0")}`:t}getTagClass(t){return t==="monitoring"?"monitoring":t==="devops"?"devops":t==="sample"?"sample":"default"}renderCreateAgentPage(t){let s=t.createDiv({cls:"af-create-agent-page"});sc(s,this.agentFormDeps())}renderCreateSkillPage(t){let s=t.createDiv({cls:"af-create-agent-page"});Hr(s,this.skillFormDeps())}renderEditAgentPage(t){let s=t.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"bot","No agent selected","");return}let r=this.plugin.runtime.getSnapshot().agents.find(i=>i.name===n);if(!r){this.renderEmptyState(s,"bot","Agent not found",`Agent "${n}" was not found`);return}nc(s,this.agentFormDeps(),r)}renderCreateTaskPage(t){let s=t.createDiv({cls:"af-create-agent-page"});oc(s,this.taskFormDeps())}renderEditTaskPage(t){let s=t.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"circle-dot","No task selected","");return}let r=this.plugin.runtime.getSnapshot().tasks.find(i=>i.taskId===n);if(!r){this.renderEmptyState(s,"circle-dot","Task not found",`Task "${n}" was not found`);return}lc(s,this.taskFormDeps(),r)}renderEditSkillPage(t){let s=t.createDiv({cls:"af-create-agent-page"}),n=this.detailContext;if(!n){this.renderEmptyState(s,"puzzle","No skill selected","");return}let r=this.plugin.runtime.getSnapshot().skills.find(i=>i.name===n);if(!r){this.renderEmptyState(s,"puzzle","Skill not found",`Skill "${n}" was not found`);return}Hr(s,this.skillFormDeps(),r)}renderAddMcpServerPage(t){let s=t.createDiv({cls:"af-create-agent-page"});cc(s,this.mcpFormDeps())}createFormField(t,s,n,r,i,o){let l=t.createDiv({cls:"af-form-row"}),c=l.createDiv({cls:"af-form-label"});c.setText(s),r&&this.addTooltip(c,r);let d=l.createEl("input",{cls:"af-form-input",attr:{type:"text",placeholder:n}});o!==void 0&&(d.value=o),d.addEventListener("input",()=>i(d.value))}addTooltip(t,s){let n=t.createSpan({cls:"af-form-tooltip"});(0,Z.setIcon)(n,"info"),n.createSpan({cls:"af-tooltip-text",text:s})}};function qr(a){return a>=1e6?`${(a/1e6).toFixed(1)}M`:a>=1e4?`${Math.round(a/1e3)}K`:a>=1e3?`${(a/1e3).toFixed(1)}K`:String(a)}function Rc(a){if(!a?.trim())return"not set";let e={"*/5 * * * *":"Every 5 minutes","*/10 * * * *":"Every 10 minutes","*/15 * * * *":"Every 15 minutes","*/30 * * * *":"Every 30 minutes","0 * * * *":"Every hour","0 */2 * * *":"Every 2 hours","0 */4 * * *":"Every 4 hours","0 */6 * * *":"Every 6 hours","0 */12 * * *":"Every 12 hours"};if(e[a])return e[a];let t=a.trim().split(/\s+/);if(t.length===5){let[s,n,r,,i]=t;if(r==="*"&&n&&s){let o=Number(n),l=Number(s);if(!isNaN(o)&&!isNaN(l)){let c=o>=12?"PM":"AM",u=`${o===0?12:o>12?o-12:o}:${String(l).padStart(2,"0")} ${c}`;return i==="*"?`Daily at ${u}`:i==="1-5"?`Weekdays at ${u}`:`${u} on days ${i}`}}}return a}var G=require("obsidian");function Da(a,e){return`${a}::${e}`}function Ic(){return Math.random().toString(16).slice(2,10)}function Mc(){return`New chat ${new Date().toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}`}function gp(a){let e=new Date(a).getTime();if(!Number.isFinite(e))return"";let t=Date.now()-e,s=60*1e3,n=60*s,r=24*n;return ts.id===this.selectedConversationId)?.name??""}async onOpen(){this.plugin.subscribeView(this),this.buildShell(),await this.render(),this.observeContainerWidth()}async onClose(){for(let{session:t}of this.sessions.values())t.abort();this.sessions.clear(),this.statsUnsub?.(),this.statsUnsub=null,this.activityUnsub?.(),this.activityUnsub=null,this.resizeObserver?.disconnect(),this.resizeObserver=null,this.plugin.unsubscribeView(this)}observeContainerWidth(){typeof ResizeObserver>"u"||(this.resizeObserver=new ResizeObserver(t=>{if(!this.userToggledCollapse)for(let s of t){let r=s.contentRect.widththis.toggleConvoPanel(),this.agentSelect=this.headerEl.createEl("select",{cls:"af-chat-view-agent-select"});let n=this.headerEl.createEl("button",{cls:"af-btn-sm af-chat-view-new-btn"});T(n,"plus","af-btn-icon"),n.appendText(" New Chat"),n.onclick=()=>void this.handleNewChat();let r=s.createDiv({cls:"af-chat-view-body"});this.convoPanelEl=r.createDiv({cls:"af-chat-convo-panel"}),this.convoPanelCollapsed&&this.convoPanelEl.addClass("collapsed");let i=r.createDiv({cls:"af-chat-main"});this.agentSelect.onchange=()=>{let d=this.agentSelect.value;d&&(this.selectedConversationId="",this.textarea.disabled=!1,this.textarea.placeholder="Message the agent\u2026 (Shift+Enter for newline)",this.switchToAgent(d))},this.messagesEl=i.createDiv({cls:"af-chat-messages"}),this.messagesInner=this.messagesEl.createDiv({cls:"af-chat-messages-inner"});let o=i.createDiv({cls:"af-chat-input-area"});this.pillsRow=o.createDiv({cls:"af-chat-pills-row"}),this.pillsRow.setCssStyles({display:"none"});let l=o.createDiv({cls:"af-chat-input-row"});this.attachStopBtn=l.createEl("button",{cls:"af-chat-attach-btn"}),T(this.attachStopBtn,"plus","af-btn-icon"),this.attachStopBtn.title="Attach active document",this.attachStopBtn.onclick=()=>{this.isInStopMode?this.handleStop():this.attachActiveDocument()},this.textarea=l.createEl("textarea",{cls:"af-chat-input",attr:{placeholder:"Message the agent\u2026 (Shift+Enter for newline)",rows:"1"}}),this.sendBtn=l.createEl("button",{cls:"af-chat-send-btn"}),T(this.sendBtn,"arrow-up","af-btn-icon"),this.sendBtn.setCssStyles({display:"none"});let c=()=>{this.textarea.setCssStyles({height:"auto"});let d=Math.min(this.textarea.scrollHeight,160);this.textarea.setCssStyles({height:`${d}px`}),this.textarea.setCssStyles({overflowY:this.textarea.scrollHeight>160?"auto":"hidden"}),this.sendBtn.setCssStyles({display:this.textarea.value.trim()?"flex":"none"})};this.textarea.addEventListener("input",c),this.textarea.addEventListener("focus",()=>{let d=this.getCurrentSession();d&&this.setStatsSource(d.session)}),this.sendBtn.onclick=()=>void this.handleSend(),this.textarea.onkeydown=d=>{d.key==="Enter"&&!d.shiftKey&&!d.isComposing&&(d.preventDefault(),this.handleSend())},this.textarea.addEventListener("paste",d=>{let u=d.clipboardData?.items;if(u)for(let h=0;h{d.preventDefault(),d.stopPropagation(),o.addClass("af-chat-input-dragover")}),o.addEventListener("dragleave",()=>{o.removeClass("af-chat-input-dragover")}),o.addEventListener("drop",d=>{d.preventDefault(),d.stopPropagation(),o.removeClass("af-chat-input-dragover");let u=d.dataTransfer?.files;if(u)for(let h=0;hthis.renderStats(s)))}renderStats(t){if(this.statsEl.empty(),!t||!t.concreteModel){this.statsEl.createSpan({cls:"af-chat-stats-muted",text:"\xA0"});return}let s=this.statsEl.createSpan({cls:"af-chat-stats-line"});if(s.createSpan({cls:"af-chat-stats-model",text:t.concreteModel}),t.contextWindow&&t.contextTokensUsed){let n=Math.min(100,Math.round(t.contextTokensUsed/t.contextWindow*100)),r=10,i=Math.min(r,Math.max(0,Math.round(n/100*r))),o="\u2593".repeat(i)+"\u2591".repeat(r-i),l=s.createSpan({cls:`af-chat-stats-ctx${n>=80?" warn":""}`});l.createSpan({cls:"af-chat-stats-bar",text:o}),l.createSpan({cls:"af-chat-stats-pct",text:`${n}%`}),l.title=`Context: ${Ds(t.contextTokensUsed)} / ${Ds(t.contextWindow)} tokens (${n}%) Includes the agent's system prompt, attached skills, tool schemas, memory, and prior turns. A fresh chat starts non-zero because all of that is loaded on turn 1. -To get 1M context on Opus, set the agent's model to claude-opus-4-7[1m] (or us.anthropic.claude-opus-4-7[1m] on Bedrock).`}if(e.lastCompact){let{preTokens:n,postTokens:a}=e.lastCompact,i=s.createSpan({cls:"af-chat-stats-compact"});i.setText(`compacted ${is(n)} \u2192 ${is(a)}`),i.title=`Conversation was summarized to free up context. ${is(n)} tokens reduced to ${is(a)}.`}}populateAgentDropdown(){let e=this.plugin.runtime.getSnapshot().agents,s=this.agentSelect.value;if(this.agentSelect.empty(),e.length===0){let a=this.agentSelect.createEl("option",{text:"No agents available",attr:{value:"",disabled:"true"}});a.selected=!0,this.textarea.disabled=!0,this.showEmptyState();return}if(!this.selectedAgentName){let a=this.agentSelect.createEl("option",{text:"Select agent\u2026",attr:{value:"",disabled:"true"}});a.selected=!0}for(let a of e){let i=a.avatar?.trim(),o=i&&!/^[a-z][a-z0-9-]*$/.test(i)?`${i} `:"";this.agentSelect.createEl("option",{text:`${o}${a.name}`,attr:{value:a.name}})}if(this.selectedAgentName&&e.some(a=>a.name===this.selectedAgentName))this.agentSelect.value=this.selectedAgentName,this.textarea.disabled=!1;else if(s&&e.some(a=>a.name===s))this.agentSelect.value=s,this.selectedAgentName=s,this.textarea.disabled=!1;else{this.selectedAgentName=null,this.leaf.updateHeader(),this.textarea.disabled=!0,this.textarea.placeholder="Select an agent to start chatting\u2026",this.showEmptyState();return}this.leaf.updateHeader(),this.textarea.placeholder="Message the agent\u2026 (Shift+Enter for newline)";let n=this.messagesInner.querySelector(".af-chat-bubble")!==null;this.selectedAgentName&&!n&&this.switchToAgent(this.selectedAgentName,this.selectedConversationId||void 0)}showEmptyState(){this.messagesInner.empty();let e=this.messagesInner.createDiv({cls:"af-chat-view-empty"}),s=e.createDiv({cls:"af-chat-view-empty-icon"});this.plugin.runtime.getSnapshot().agents.length===0?((0,z.setIcon)(s,"bot"),e.createDiv({cls:"af-chat-view-empty-text",text:"No agents available"}),e.createDiv({cls:"af-chat-view-empty-hint",text:"Create an agent to start chatting"})):((0,z.setIcon)(s,"message-circle"),e.createDiv({cls:"af-chat-view-empty-text",text:"Select an agent to start"}),e.createDiv({cls:"af-chat-view-empty-hint",text:"Choose an agent from the dropdown above"}))}async switchToAgent(e,s){let a=this.plugin.runtime.getSnapshot().agents.find(h=>h.name===e);if(!a)return;await this.loadConversations(a);let i;if(s&&this.conversationsCache.some(h=>h.id===s))i=s;else if(this.conversationsCache.length>0)i=this.conversationsCache[0].id;else{i=tl();try{await this.plugin.repository.createConversation(a,i,sl())}catch(h){new z.Notice(`Couldn't create conversation: ${h instanceof Error?h.message:String(h)}`);return}await this.loadConversations(a)}if(!s||s!==i){let h=this.plugin.app.workspace.getLeavesOfType(rt);for(let d of h)if(d.view!==this&&d.view instanceof r&&d.view.selectedAgentName===e&&d.view.selectedConversationId===i){this.plugin.app.workspace.revealLeaf(d);return}}this.selectedAgentName=e,this.selectedConversationId=i,this.leaf.updateHeader(),this.activityEl=null,this.streamingDot=null,this.messagesInner.empty(),this.threadExpanded.clear(),this.renderConvoPanel();let o=qn(e,i),c=this.sessions.get(o);if(!c){let h=new Yt(a,this.plugin.settings,this.plugin.repository,this.app.vault,{inAppConversationId:i,mcpAuth:this.plugin.mcpAuth});h.setUsageRecorder(d=>this.plugin.runtime.recordUsage(d)),c={session:h},this.sessions.set(o,c),await h.loadPersistedState()}this.setStatsSource(c.session);for(let h of c.session.messages)if(h.role==="user")this.addBubble("user",h.content,h.attachments);else{let d=this.addBubble("assistant");if(this.renderMarkdownBubble(d,h.content),d._setRawText?.(h.content),this.attachThreadAffordance(d,h.id,c.session),h.toolCalls&&h.toolCalls.length>0){let u=this.getOrCreateAffordancesRow(d);this.buildToolSummary(h.toolCalls,u)}}this.activityUnsub?.();let l=c.session;this.activityUnsub=l.onActivityChange(()=>{this.getCurrentSession()?.session===l&&this.renderIndicators(l)}),this.textarea.disabled=!1,this.textarea.focus()}getCurrentSession(){if(this.selectedAgentName)return this.sessions.get(qn(this.selectedAgentName,this.selectedConversationId))}async loadConversations(e){this.conversationsCache=await this.plugin.repository.listConversations(e)}async refreshConversationsList(e){await this.loadConversations(e),this.renderConvoPanel(),this.leaf.updateHeader()}renderConvoPanel(){if(!this.convoPanelEl||(this.convoPanelEl.empty(),!this.selectedAgentName))return;this.convoPanelEl.createDiv({cls:"af-chat-convo-header",text:"Conversations"});let e=this.convoPanelEl.createDiv({cls:"af-chat-convo-new"}),s=e.createSpan({cls:"af-chat-convo-new-icon"});(0,z.setIcon)(s,"plus"),e.createSpan({cls:"af-chat-convo-new-label",text:"New chat"}),e.onclick=()=>void this.handleNewChat();let n=this.convoPanelEl.createDiv({cls:"af-chat-convo-list"});for(let a of this.conversationsCache)this.renderConvoRow(n,a)}renderConvoRow(e,s){let n=s.id===this.selectedConversationId,a=e.createDiv({cls:`af-chat-convo-item${n?" active":""}`}),i=a.createDiv({cls:"af-chat-convo-name",text:s.name});i.title=s.name,i.ondblclick=h=>{h.stopPropagation(),this.beginInlineRename(i,s)};let o=a.createDiv({cls:"af-chat-convo-meta"}),c=s.messageCount===1?"1 msg":`${s.messageCount} msgs`;o.appendText(`${c} \xB7 ${Ch(s.lastActive)}`);let l=a.createEl("button",{cls:"af-chat-convo-trash",attr:{title:"Delete conversation","aria-label":"Delete conversation"}});(0,z.setIcon)(l,"trash-2"),l.onclick=h=>{h.stopPropagation(),this.handleDeleteConversation(s)},a.onclick=()=>{s.id!==this.selectedConversationId&&this.handleConvoSelected(s.id)}}beginInlineRename(e,s){let n=s.name,a=activeDocument.createElement("input");a.type="text",a.value=n,a.className="af-chat-convo-name-input",e.replaceWith(a),a.focus(),a.select();let i=!1,o=l=>{let h=activeDocument.createElement("div");return h.className="af-chat-convo-name",h.textContent=l??n,h.title=l??n,h.ondblclick=d=>{d.stopPropagation();let u=this.conversationsCache.find(p=>p.id===s.id)??s;this.beginInlineRename(h,u)},a.replaceWith(h),h},c=async()=>{if(i)return;i=!0;let l=a.value.trim();if(!l||l===n){o(null);return}try{await this.saveConversationName(s.id,l)}catch(h){new z.Notice(`Couldn't rename: ${h instanceof Error?h.message:String(h)}`),o(null);return}this.renderConvoPanel(),this.leaf.updateHeader()};a.addEventListener("keydown",l=>{l.key==="Enter"?(l.preventDefault(),c()):l.key==="Escape"&&(l.preventDefault(),i=!0,o(null))}),a.addEventListener("blur",()=>{c()})}async saveConversationName(e,s){if(!this.selectedAgentName)return;let a=this.plugin.runtime.getSnapshot().agents.find(l=>l.name===this.selectedAgentName);if(!a)return;try{await this.plugin.repository.renameConversation(a,e,s)}catch{}let i=qn(this.selectedAgentName,e),o=this.sessions.get(i);o&&await o.session.setConversationName(s);let c=this.conversationsCache.findIndex(l=>l.id===e);c>=0&&(this.conversationsCache[c]={...this.conversationsCache[c],name:s})}async handleConvoSelected(e){if(!this.selectedAgentName)return;let s=this.plugin.app.workspace.getLeavesOfType(rt);for(let n of s)if(n.view!==this&&n.view instanceof r&&n.view.selectedAgentName===this.selectedAgentName&&n.view.selectedConversationId===e){this.plugin.app.workspace.revealLeaf(n);return}await this.switchToAgent(this.selectedAgentName,e)}handleDeleteConversation(e){let s=this.selectedAgentName;if(!s)return;let a=this.plugin.runtime.getSnapshot().agents.find(i=>i.name===s);a&&new qt(this.app,{title:`Delete conversation "${e.name}"?`,body:"This removes its message history. The agent and its other conversations are untouched.",confirmText:"Delete",danger:!0,onConfirm:async()=>{let i=qn(s,e.id);this.sessions.get(i)?.session.dispose(),this.sessions.delete(i);try{await this.plugin.repository.deleteConversation(a,e.id)}catch(o){new z.Notice(`Couldn't delete: ${o instanceof Error?o.message:String(o)}`);return}if(await this.refreshConversationsList(a),e.id===this.selectedConversationId){let o=this.conversationsCache[0]?.id;await this.switchToAgent(s,o)}}}).open()}toggleConvoPanel(){this.convoPanelCollapsed=!this.convoPanelCollapsed,this.userToggledCollapse=!0,this.applyCollapsedClass(),this.leaf.updateHeader()}applyCollapsedClass(){this.convoPanelEl&&(this.convoPanelCollapsed?this.convoPanelEl.addClass("collapsed"):this.convoPanelEl.removeClass("collapsed")),this.collapseBtn&&this.collapseBtn.toggleClass("is-collapsed",this.convoPanelCollapsed)}renderMarkdownBubble(e,s){let n=e.querySelector(".af-chat-copy-btn"),a=n?.parentNode?.removeChild(n)??null;e.empty(),e.addClass("af-compact-md"),z.MarkdownRenderer.render(this.app,s,e,"",this).then(()=>{a&&e.appendChild(a),this.wireBubbleLinks(e),e.querySelectorAll("pre").forEach(i=>{i.querySelector(".copy-code-button")?.remove();let o=i.querySelector("code");if(!o)return;let c=activeDocument.createElement("button");c.className="af-code-copy-btn",c.setAttribute("aria-label","Copy code"),(0,z.setIcon)(c,"copy"),c.onclick=l=>{l.stopPropagation(),navigator.clipboard.writeText(o.textContent??"").then(()=>{c.addClass("copied"),(0,z.setIcon)(c,"check"),window.setTimeout(()=>{c.removeClass("copied"),(0,z.setIcon)(c,"copy")},1500)})},i.setCssStyles({position:"relative"}),i.appendChild(c)})})}wireBubbleLinks(e){e.addEventListener("click",s=>{let n=s.target.closest("a");if(!n)return;if(n.classList.contains("internal-link")){s.preventDefault(),s.stopPropagation();let i=n.getAttribute("data-href")||n.getAttribute("href")||n.textContent||"";if(!i)return;let o=s.shiftKey?"split":"tab";this.app.workspace.openLinkText(i,"",o);return}let a=n.getAttribute("href")||"";if(a.startsWith("obsidian://")){s.preventDefault(),s.stopPropagation(),window.location.href=a;return}if(n.classList.contains("external-link")||/^https?:\/\//.test(a)){s.preventDefault(),s.stopPropagation(),window.open(a,"_blank");return}})}addCopyBtn(e,s){let n=e.createEl("button",{cls:"af-chat-copy-btn",attr:{"aria-label":"Copy message"}});(0,z.setIcon)(n,"copy"),n.onclick=a=>{a.stopPropagation(),navigator.clipboard.writeText(s()).then(()=>{n.addClass("copied"),(0,z.setIcon)(n,"check"),window.setTimeout(()=>{n.removeClass("copied"),(0,z.setIcon)(n,"copy")},1500)})}}addBubble(e,s,n){if(e==="user"&&n&&n.length>0){let i=this.messagesInner.createDiv({cls:"af-chat-bubble-attachments"});for(let o of n){let c=i.createSpan({cls:"af-chat-pill af-chat-pill-inline"}),l=c.createSpan({cls:"af-chat-pill-icon"}),h=/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(o);(0,z.setIcon)(l,h?"image":"file-text"),c.createSpan({cls:"af-chat-pill-name",text:o})}}let a=this.messagesInner.createDiv({cls:`af-chat-bubble af-chat-bubble-${e}`});if(s&&(e==="assistant"?this.renderMarkdownBubble(a,s):a.setText(s)),e==="assistant"){let i=s??"";this.addCopyBtn(a,()=>i),a._setRawText=o=>{i=o}}return this.messagesEl.scrollTop=this.messagesEl.scrollHeight,a}getOrCreateAffordancesRow(e){let s=e.parentElement;if(!s)throw new Error("bubble has no parent");let n=e.nextElementSibling;if(n&&n.classList.contains("af-chat-affordances"))return n;let a=activeDocument.createElement("div");return a.className="af-chat-affordances",s.insertBefore(a,e.nextSibling),a}attachThreadAffordance(e,s,n){let a=e.parentElement;if(!a)return;let i=this.getOrCreateAffordancesRow(e);if(i.querySelector(".af-thread-badge"))return;let o=activeDocument.createElement("div");o.className="af-thread-badge",o.setAttribute("role","button"),o.setAttribute("tabindex","0"),i.appendChild(o),(0,z.setIcon)(o,"message-circle");let c=o.createSpan({cls:"af-thread-badge-label"}),l=activeDocument.createElement("div");l.className="af-thread-container",l.setCssStyles({display:"none"}),a.insertBefore(l,i.nextSibling);let h=()=>{let f=n.getThreadIndex()[s]?.messageCount??0;f<=0?c.setText("Thread"):c.setText(`${f} ${f===1?"reply":"replies"}`),f>0&&o.addClass("has-replies")};h();let d=!1,u=async()=>{if(this.threadExpanded.get(s)===!0){l.setCssStyles({display:"none"}),this.threadExpanded.set(s,!1),o.removeClass("expanded"),this.setStatsSource(n);return}if(this.threadExpanded.set(s,!0),l.setCssStyles({display:""}),o.addClass("expanded"),!d)try{let m=await n.openOrCreateThread(s);this.renderThreadContainer(l,m,n,h),d=!0}catch(m){l.setText(`Failed to open thread: ${m instanceof Error?m.message:String(m)}`)}};o.onclick=()=>void u(),o.onkeydown=p=>{(p.key==="Enter"||p.key===" ")&&(p.preventDefault(),u())}}renderThreadContainer(e,s,n,a){e.empty();let i=e.createDiv({cls:"af-thread-wrap"}),o=i.createDiv({cls:"af-thread-messages"}),c=null,l=null,h=C=>{C?(c||(c=o.createDiv({cls:"af-chat-activity"})),c.setText(`Working\u2026 (${C})`)):c&&(c.remove(),c=null)},d=C=>{if(C&&!l){l=o.createDiv({cls:"af-chat-streaming-dot"});for(let E=0;E<3;E++)l.createSpan()}else!C&&l&&(l.remove(),l=null)},u=(C,E,P)=>{if(C==="user"&&P&&P.length>0){let M=o.createDiv({cls:"af-chat-bubble-attachments"});for(let U of P){let $=M.createSpan({cls:"af-chat-pill af-chat-pill-inline"}),Z=$.createSpan({cls:"af-chat-pill-icon"});(0,z.setIcon)(Z,U.match(/\.(png|jpe?g|gif|webp|svg)$/i)?"image":"file-text"),$.createSpan({cls:"af-chat-pill-name",text:U})}}let N=o.createDiv({cls:`af-thread-bubble af-thread-bubble-${C}`});return C==="assistant"?this.renderMarkdownBubble(N,E):N.setText(E),N};for(let C of s.messages)u(C.role,C.content,C.attachments);let p=[],m=[],f=i.createDiv({cls:"af-thread-composer-wrap"}),g=f.createDiv({cls:"af-chat-pills-row af-thread-pills-row"});g.setCssStyles({display:"none"});let y=()=>{if(g.empty(),p.length===0&&m.length===0){g.setCssStyles({display:"none"});return}g.setCssStyles({display:"flex"});for(let C of p){let E=g.createDiv({cls:"af-chat-pill"}),P=E.createSpan({cls:"af-chat-pill-icon"});(0,z.setIcon)(P,"file-text"),E.createSpan({cls:"af-chat-pill-name",text:C.name});let N=E.createSpan({cls:"af-chat-pill-remove"});(0,z.setIcon)(N,"x"),N.onclick=M=>{M.stopPropagation();let U=p.findIndex($=>$.path===C.path);U>=0&&p.splice(U,1),y()}}for(let C of m){let E=g.createDiv({cls:"af-chat-pill"}),P=E.createSpan({cls:"af-chat-pill-icon"});(0,z.setIcon)(P,"image"),E.createSpan({cls:"af-chat-pill-name",text:C.name});let N=E.createSpan({cls:"af-chat-pill-remove"});(0,z.setIcon)(N,"x"),N.onclick=M=>{M.stopPropagation();let U=m.findIndex($=>$.path===C.path);U>=0&&m.splice(U,1),y()}}},v=()=>{let C=this.app.workspace.getActiveFile();if(!C){new z.Notice("No active document to attach");return}if(p.some(P=>P.path===C.path)){new z.Notice(`"${C.name}" is already attached`);return}if(!new Set(["md","txt","json","yaml","yml","toml","csv","xml","html","css","js","ts","py","sh","sql","env","cfg","ini","log"]).has(C.extension.toLowerCase())){new z.Notice(`Can't attach "${C.name}" \u2014 only text files are supported`);return}p.push(C),y()},k=async C=>{let E=C.type.split("/")[1]?.replace("jpeg","jpg")??"png",P=C.name&&C.name!=="image"?C.name:`pasted-${Date.now()}.${E}`;if(m.some(M=>M.name===P)){new z.Notice(`"${P}" is already attached`);return}let N=await this.saveImageBlobToVault(C);N&&(m.push(N),y())},w=f.createDiv({cls:"af-chat-input-row af-thread-composer"}),S=w.createEl("button",{cls:"af-chat-attach-btn"});R(S,"plus","af-btn-icon"),S.title="Attach active document",S.onclick=C=>{C.preventDefault(),v()};let T=w.createEl("textarea",{cls:"af-chat-input af-thread-input",attr:{placeholder:"Message in thread\u2026 (Shift+Enter for newline)",rows:"1"}});T.addEventListener("paste",C=>{let E=C.clipboardData?.items;if(E)for(let P=0;P{C.preventDefault(),C.stopPropagation(),f.addClass("af-chat-input-dragover")}),f.addEventListener("dragleave",()=>{f.removeClass("af-chat-input-dragover")}),f.addEventListener("drop",C=>{C.preventDefault(),C.stopPropagation(),f.removeClass("af-chat-input-dragover");let E=C.dataTransfer?.files;if(E)for(let P=0;P{T.setCssStyles({height:"auto"}),T.setCssStyles({height:`${Math.min(T.scrollHeight,120)}px`}),_.setCssStyles({display:T.value.trim()?"flex":"none"})};T.addEventListener("input",D),T.addEventListener("focus",()=>this.setStatsSource(s));let O=async()=>{let C=T.value.trim();if(!C||s.isStreaming)return;let E=await this.buildAttachmentContextFor(p,m),P=[...p.map($=>$.name),...m.map($=>$.name)],N=E?`${E}${C}`:void 0;T.value="",D(),p.length=0,m.length=0,y(),u("user",C,P.length>0?P:void 0),d(!0);let M=null,U="";try{await s.sendMessage(C,$=>{if($.type==="text"){M||(d(!1),h(),M=u("assistant",""),M.empty()),U+=$.content;let Z=M.querySelector(".af-chat-stream-text");Z||(Z=M.createDiv({cls:"af-chat-stream-text"})),Z.setText(U)}else $.type==="tool_use"?h($.toolName):$.type==="result"&&(h(),d(!1),M&&this.renderMarkdownBubble(M,At(U)))},N,P.length>0?P:void 0),a()}catch($){d(!1),h();let Z=$ instanceof Error?$.message:String($);o.createDiv({cls:"af-thread-error",text:`Error: ${Z}`})}};_.onclick=()=>void O(),T.onkeydown=C=>{C.key==="Enter"&&!C.shiftKey&&!C.isComposing&&(C.preventDefault(),O())}}buildToolSummary(e,s){let a=(s??this.messagesInner).createDiv({cls:"af-chat-tool-summary"}),i=a.createEl("details"),o=i.createEl("summary"),c=new Map;for(let d of e){let u=c.get(d.name)??[];d.command&&u.push(d.command),c.set(d.name,u)}let l=o.createSpan({cls:"af-chat-tool-icon"});(0,z.setIcon)(l,"wrench"),o.appendText(` ${e.length} tool call${e.length!==1?"s":""}`);let h=i.createDiv({cls:"af-chat-tool-list"});for(let[d,u]of c){let p=u.length||(c.get(d)?.length??1),m=h.createDiv({cls:"af-chat-tool-item"}),f=p>1?`${d} (\xD7${p})`:d;m.createSpan({cls:"af-chat-tool-name",text:f}),u.length===1&&u[0]&&m.createSpan({cls:"af-chat-tool-cmd",text:u[0]})}return a}renderIndicators(e){let s=e.isStreaming,n=e.currentToolName,a=e.hasCurrentTurnText,i=!!this.messagesInner.querySelector(".af-chat-stream-text"),o=null;if(s&&n?o=`Working\u2026 (${n})`:s&&a&&!i&&(o="Replying\u2026"),o?(this.activityEl?(this.activityEl.parentElement!==this.messagesInner||this.activityEl.nextElementSibling!==null)&&this.messagesInner.appendChild(this.activityEl):this.activityEl=this.messagesInner.createDiv({cls:"af-chat-activity"}),this.activityEl.textContent!==o&&this.activityEl.setText(o)):this.activityEl&&(this.activityEl.remove(),this.activityEl=null),s&&!n&&!a)if(this.streamingDot)(this.streamingDot.parentElement!==this.messagesInner||this.streamingDot.nextElementSibling!==null)&&this.messagesInner.appendChild(this.streamingDot);else{this.streamingDot=this.messagesInner.createDiv({cls:"af-chat-streaming-dot"});for(let l=0;l<3;l++)this.streamingDot.createSpan()}else this.streamingDot&&(this.streamingDot.remove(),this.streamingDot=null);this.setAttachStopMode(s)}setAttachStopMode(e){e!==this.isInStopMode&&(this.isInStopMode=e,this.attachStopBtn.empty(),e?(R(this.attachStopBtn,"square","af-btn-icon"),this.attachStopBtn.title="Stop generation",this.attachStopBtn.addClass("af-chat-stop-mode")):(R(this.attachStopBtn,"plus","af-btn-icon"),this.attachStopBtn.title="Attach active document",this.attachStopBtn.removeClass("af-chat-stop-mode")))}handleStop(){let e=this.getCurrentSession();e&&(e.session.abort(),this.addBubble("error","Generation stopped"))}attachActiveDocument(){let e=this.app.workspace.getActiveFile();if(!e){new z.Notice("No active document to attach");return}if(this.attachedFiles.some(a=>a.path===e.path)){new z.Notice(`"${e.name}" is already attached`);return}let s=e.extension.toLowerCase();if(!new Set(["md","txt","json","yaml","yml","toml","csv","xml","html","css","js","ts","py","sh","sql","env","cfg","ini","log"]).has(s)){new z.Notice(`Can't attach "${e.name}" \u2014 only text files are supported`);return}this.attachedFiles.push(e),this.renderPills()}async saveImageBlobToVault(e){let s=e.type.split("/")[1]?.replace("jpeg","jpg")??"png",n=Date.now(),a=e.name&&e.name!=="image"?e.name:`pasted-${n}.${s}`,i=`${this.plugin.settings.fleetFolder}/chat-images`,o=`${i}/${n}-${a}`;try{this.app.vault.getAbstractFileByPath(i)||await this.app.vault.createFolder(i);let c=await e.arrayBuffer();await this.app.vault.createBinary(o,c);let h=this.app.vault.adapter.getBasePath?.()??"";return{name:a,path:`${h}/${o}`}}catch(c){let l=c instanceof Error?c.message:String(c);return new z.Notice(`Failed to save image: ${l}`),null}}async buildAttachmentContextFor(e,s){if(e.length===0&&s.length===0)return"";let n=[];for(let a of e)try{let i=await this.app.vault.cachedRead(a);n.push(`### ${a.name} +To get 1M context on Opus, set the agent's model to claude-opus-4-7[1m] (or us.anthropic.claude-opus-4-7[1m] on Bedrock).`}if(t.lastCompact){let{preTokens:n,postTokens:r}=t.lastCompact,i=s.createSpan({cls:"af-chat-stats-compact"});i.setText(`compacted ${Ds(n)} \u2192 ${Ds(r)}`),i.title=`Conversation was summarized to free up context. ${Ds(n)} tokens reduced to ${Ds(r)}.`}}populateAgentDropdown(){let t=this.plugin.runtime.getSnapshot().agents,s=this.agentSelect.value;if(this.agentSelect.empty(),t.length===0){let r=this.agentSelect.createEl("option",{text:"No agents available",attr:{value:"",disabled:"true"}});r.selected=!0,this.textarea.disabled=!0,this.showEmptyState();return}if(!this.selectedAgentName){let r=this.agentSelect.createEl("option",{text:"Select agent\u2026",attr:{value:"",disabled:"true"}});r.selected=!0}for(let r of t){let i=r.avatar?.trim(),o=i&&!/^[a-z][a-z0-9-]*$/.test(i)?`${i} `:"";this.agentSelect.createEl("option",{text:`${o}${r.name}`,attr:{value:r.name}})}if(this.selectedAgentName&&t.some(r=>r.name===this.selectedAgentName))this.agentSelect.value=this.selectedAgentName,this.textarea.disabled=!1;else if(s&&t.some(r=>r.name===s))this.agentSelect.value=s,this.selectedAgentName=s,this.textarea.disabled=!1;else{this.selectedAgentName=null,this.leaf.updateHeader(),this.textarea.disabled=!0,this.textarea.placeholder="Select an agent to start chatting\u2026",this.showEmptyState();return}this.leaf.updateHeader(),this.textarea.placeholder="Message the agent\u2026 (Shift+Enter for newline)";let n=this.messagesInner.querySelector(".af-chat-bubble")!==null;this.selectedAgentName&&!n&&this.switchToAgent(this.selectedAgentName,this.selectedConversationId||void 0)}showEmptyState(){this.messagesInner.empty();let t=this.messagesInner.createDiv({cls:"af-chat-view-empty"}),s=t.createDiv({cls:"af-chat-view-empty-icon"});this.plugin.runtime.getSnapshot().agents.length===0?((0,G.setIcon)(s,"bot"),t.createDiv({cls:"af-chat-view-empty-text",text:"No agents available"}),t.createDiv({cls:"af-chat-view-empty-hint",text:"Create an agent to start chatting"})):((0,G.setIcon)(s,"message-circle"),t.createDiv({cls:"af-chat-view-empty-text",text:"Select an agent to start"}),t.createDiv({cls:"af-chat-view-empty-hint",text:"Choose an agent from the dropdown above"}))}async switchToAgent(t,s){let r=this.plugin.runtime.getSnapshot().agents.find(d=>d.name===t);if(!r)return;await this.loadConversations(r);let i;if(s&&this.conversationsCache.some(d=>d.id===s))i=s;else if(this.conversationsCache.length>0)i=this.conversationsCache[0].id;else{i=Ic();try{await this.plugin.repository.createConversation(r,i,Mc())}catch(d){new G.Notice(`Couldn't create conversation: ${d instanceof Error?d.message:String(d)}`);return}await this.loadConversations(r)}if(!s||s!==i){let d=this.plugin.app.workspace.getLeavesOfType(mt);for(let u of d)if(u.view!==this&&u.view instanceof a&&u.view.selectedAgentName===t&&u.view.selectedConversationId===i){this.plugin.app.workspace.revealLeaf(u);return}}this.selectedAgentName=t,this.selectedConversationId=i,this.leaf.updateHeader(),this.activityEl=null,this.streamingDot=null,this.messagesInner.empty(),this.threadExpanded.clear(),this.renderConvoPanel();let o=Da(t,i),l=this.sessions.get(o);if(!l){let d=new ys(r,this.plugin.settings,this.plugin.repository,this.app.vault,{inAppConversationId:i,mcpAuth:this.plugin.mcpAuth});d.setUsageRecorder(u=>this.plugin.runtime.recordUsage(u)),l={session:d},this.sessions.set(o,l),await d.loadPersistedState()}this.setStatsSource(l.session);for(let d of l.session.messages)if(d.role==="user")this.addBubble("user",d.content,d.attachments);else{let u=this.addBubble("assistant");if(this.renderMarkdownBubble(u,d.content),u._setRawText?.(d.content),this.attachThreadAffordance(u,d.id,l.session),d.toolCalls&&d.toolCalls.length>0){let h=this.getOrCreateAffordancesRow(u);this.buildToolSummary(d.toolCalls,h)}}this.activityUnsub?.();let c=l.session;this.activityUnsub=c.onActivityChange(()=>{this.getCurrentSession()?.session===c&&this.renderIndicators(c)}),this.textarea.disabled=!1,this.textarea.focus()}getCurrentSession(){if(this.selectedAgentName)return this.sessions.get(Da(this.selectedAgentName,this.selectedConversationId))}async loadConversations(t){this.conversationsCache=await this.plugin.repository.listConversations(t)}async refreshConversationsList(t){await this.loadConversations(t),this.renderConvoPanel(),this.leaf.updateHeader()}renderConvoPanel(){if(!this.convoPanelEl||(this.convoPanelEl.empty(),!this.selectedAgentName))return;this.convoPanelEl.createDiv({cls:"af-chat-convo-header",text:"Conversations"});let t=this.convoPanelEl.createDiv({cls:"af-chat-convo-new"}),s=t.createSpan({cls:"af-chat-convo-new-icon"});(0,G.setIcon)(s,"plus"),t.createSpan({cls:"af-chat-convo-new-label",text:"New chat"}),t.onclick=()=>void this.handleNewChat();let n=this.convoPanelEl.createDiv({cls:"af-chat-convo-list"});for(let r of this.conversationsCache)this.renderConvoRow(n,r)}renderConvoRow(t,s){let n=s.id===this.selectedConversationId,r=t.createDiv({cls:`af-chat-convo-item${n?" active":""}`}),i=r.createDiv({cls:"af-chat-convo-name",text:s.name});i.title=s.name,i.ondblclick=d=>{d.stopPropagation(),this.beginInlineRename(i,s)};let o=r.createDiv({cls:"af-chat-convo-meta"}),l=s.messageCount===1?"1 msg":`${s.messageCount} msgs`;o.appendText(`${l} \xB7 ${gp(s.lastActive)}`);let c=r.createEl("button",{cls:"af-chat-convo-trash",attr:{title:"Delete conversation","aria-label":"Delete conversation"}});(0,G.setIcon)(c,"trash-2"),c.onclick=d=>{d.stopPropagation(),this.handleDeleteConversation(s)},r.onclick=()=>{s.id!==this.selectedConversationId&&this.handleConvoSelected(s.id)}}beginInlineRename(t,s){let n=s.name,r=activeDocument.createElement("input");r.type="text",r.value=n,r.className="af-chat-convo-name-input",t.replaceWith(r),r.focus(),r.select();let i=!1,o=c=>{let d=activeDocument.createElement("div");return d.className="af-chat-convo-name",d.textContent=c??n,d.title=c??n,d.ondblclick=u=>{u.stopPropagation();let h=this.conversationsCache.find(p=>p.id===s.id)??s;this.beginInlineRename(d,h)},r.replaceWith(d),d},l=async()=>{if(i)return;i=!0;let c=r.value.trim();if(!c||c===n){o(null);return}try{await this.saveConversationName(s.id,c)}catch(d){new G.Notice(`Couldn't rename: ${d instanceof Error?d.message:String(d)}`),o(null);return}this.renderConvoPanel(),this.leaf.updateHeader()};r.addEventListener("keydown",c=>{c.key==="Enter"?(c.preventDefault(),l()):c.key==="Escape"&&(c.preventDefault(),i=!0,o(null))}),r.addEventListener("blur",()=>{l()})}async saveConversationName(t,s){if(!this.selectedAgentName)return;let r=this.plugin.runtime.getSnapshot().agents.find(c=>c.name===this.selectedAgentName);if(!r)return;try{await this.plugin.repository.renameConversation(r,t,s)}catch{}let i=Da(this.selectedAgentName,t),o=this.sessions.get(i);o&&await o.session.setConversationName(s);let l=this.conversationsCache.findIndex(c=>c.id===t);l>=0&&(this.conversationsCache[l]={...this.conversationsCache[l],name:s})}async handleConvoSelected(t){if(!this.selectedAgentName)return;let s=this.plugin.app.workspace.getLeavesOfType(mt);for(let n of s)if(n.view!==this&&n.view instanceof a&&n.view.selectedAgentName===this.selectedAgentName&&n.view.selectedConversationId===t){this.plugin.app.workspace.revealLeaf(n);return}await this.switchToAgent(this.selectedAgentName,t)}handleDeleteConversation(t){let s=this.selectedAgentName;if(!s)return;let r=this.plugin.runtime.getSnapshot().agents.find(i=>i.name===s);r&&new ut(this.app,{title:`Delete conversation "${t.name}"?`,body:"This removes its message history. The agent and its other conversations are untouched.",confirmText:"Delete",danger:!0,onConfirm:async()=>{let i=Da(s,t.id);this.sessions.get(i)?.session.dispose(),this.sessions.delete(i);try{await this.plugin.repository.deleteConversation(r,t.id)}catch(o){new G.Notice(`Couldn't delete: ${o instanceof Error?o.message:String(o)}`);return}if(await this.refreshConversationsList(r),t.id===this.selectedConversationId){let o=this.conversationsCache[0]?.id;await this.switchToAgent(s,o)}}}).open()}toggleConvoPanel(){this.convoPanelCollapsed=!this.convoPanelCollapsed,this.userToggledCollapse=!0,this.applyCollapsedClass(),this.leaf.updateHeader()}applyCollapsedClass(){this.convoPanelEl&&(this.convoPanelCollapsed?this.convoPanelEl.addClass("collapsed"):this.convoPanelEl.removeClass("collapsed")),this.collapseBtn&&this.collapseBtn.toggleClass("is-collapsed",this.convoPanelCollapsed)}renderMarkdownBubble(t,s){let n=t.querySelector(".af-chat-copy-btn"),r=n?.parentNode?.removeChild(n)??null;t.empty(),t.addClass("af-compact-md"),G.MarkdownRenderer.render(this.app,s,t,"",this).then(()=>{r&&t.appendChild(r),this.wireBubbleLinks(t),t.querySelectorAll("pre").forEach(i=>{i.querySelector(".copy-code-button")?.remove();let o=i.querySelector("code");if(!o)return;let l=activeDocument.createElement("button");l.className="af-code-copy-btn",l.setAttribute("aria-label","Copy code"),(0,G.setIcon)(l,"copy"),l.onclick=c=>{c.stopPropagation(),navigator.clipboard.writeText(o.textContent??"").then(()=>{l.addClass("copied"),(0,G.setIcon)(l,"check"),window.setTimeout(()=>{l.removeClass("copied"),(0,G.setIcon)(l,"copy")},1500)})},i.setCssStyles({position:"relative"}),i.appendChild(l)})})}wireBubbleLinks(t){t.addEventListener("click",s=>{let n=s.target.closest("a");if(!n)return;if(n.classList.contains("internal-link")){s.preventDefault(),s.stopPropagation();let i=n.getAttribute("data-href")||n.getAttribute("href")||n.textContent||"";if(!i)return;let o=s.shiftKey?"split":"tab";this.app.workspace.openLinkText(i,"",o);return}let r=n.getAttribute("href")||"";if(r.startsWith("obsidian://")){s.preventDefault(),s.stopPropagation(),window.location.href=r;return}if(n.classList.contains("external-link")||/^https?:\/\//.test(r)){s.preventDefault(),s.stopPropagation(),window.open(r,"_blank");return}})}addCopyBtn(t,s){let n=t.createEl("button",{cls:"af-chat-copy-btn",attr:{"aria-label":"Copy message"}});(0,G.setIcon)(n,"copy"),n.onclick=r=>{r.stopPropagation(),navigator.clipboard.writeText(s()).then(()=>{n.addClass("copied"),(0,G.setIcon)(n,"check"),window.setTimeout(()=>{n.removeClass("copied"),(0,G.setIcon)(n,"copy")},1500)})}}addBubble(t,s,n){if(t==="user"&&n&&n.length>0){let i=this.messagesInner.createDiv({cls:"af-chat-bubble-attachments"});for(let o of n){let l=i.createSpan({cls:"af-chat-pill af-chat-pill-inline"}),c=l.createSpan({cls:"af-chat-pill-icon"}),d=/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(o);(0,G.setIcon)(c,d?"image":"file-text"),l.createSpan({cls:"af-chat-pill-name",text:o})}}let r=this.messagesInner.createDiv({cls:`af-chat-bubble af-chat-bubble-${t}`});if(s&&(t==="assistant"?this.renderMarkdownBubble(r,s):r.setText(s)),t==="assistant"){let i=s??"";this.addCopyBtn(r,()=>i),r._setRawText=o=>{i=o}}return this.messagesEl.scrollTop=this.messagesEl.scrollHeight,r}getOrCreateAffordancesRow(t){let s=t.parentElement;if(!s)throw new Error("bubble has no parent");let n=t.nextElementSibling;if(n&&n.classList.contains("af-chat-affordances"))return n;let r=activeDocument.createElement("div");return r.className="af-chat-affordances",s.insertBefore(r,t.nextSibling),r}attachThreadAffordance(t,s,n){let r=t.parentElement;if(!r)return;let i=this.getOrCreateAffordancesRow(t);if(i.querySelector(".af-thread-badge"))return;let o=activeDocument.createElement("div");o.className="af-thread-badge",o.setAttribute("role","button"),o.setAttribute("tabindex","0"),i.appendChild(o),(0,G.setIcon)(o,"message-circle");let l=o.createSpan({cls:"af-thread-badge-label"}),c=activeDocument.createElement("div");c.className="af-thread-container",c.setCssStyles({display:"none"}),r.insertBefore(c,i.nextSibling);let d=()=>{let f=n.getThreadIndex()[s]?.messageCount??0;f<=0?l.setText("Thread"):l.setText(`${f} ${f===1?"reply":"replies"}`),f>0&&o.addClass("has-replies")};d();let u=!1,h=async()=>{if(this.threadExpanded.get(s)===!0){c.setCssStyles({display:"none"}),this.threadExpanded.set(s,!1),o.removeClass("expanded"),this.setStatsSource(n);return}if(this.threadExpanded.set(s,!0),c.setCssStyles({display:""}),o.addClass("expanded"),!u)try{let m=await n.openOrCreateThread(s);this.renderThreadContainer(c,m,n,d),u=!0}catch(m){c.setText(`Failed to open thread: ${m instanceof Error?m.message:String(m)}`)}};o.onclick=()=>void h(),o.onkeydown=p=>{(p.key==="Enter"||p.key===" ")&&(p.preventDefault(),h())}}renderThreadContainer(t,s,n,r){t.empty();let i=t.createDiv({cls:"af-thread-wrap"}),o=i.createDiv({cls:"af-thread-messages"}),l=null,c=null,d=x=>{x?(l||(l=o.createDiv({cls:"af-chat-activity"})),l.setText(`Working\u2026 (${x})`)):l&&(l.remove(),l=null)},u=x=>{if(x&&!c){c=o.createDiv({cls:"af-chat-streaming-dot"});for(let _=0;_<3;_++)c.createSpan()}else!x&&c&&(c.remove(),c=null)},h=(x,_,R)=>{if(x==="user"&&R&&R.length>0){let F=o.createDiv({cls:"af-chat-bubble-attachments"});for(let B of R){let q=F.createSpan({cls:"af-chat-pill af-chat-pill-inline"}),oe=q.createSpan({cls:"af-chat-pill-icon"});(0,G.setIcon)(oe,B.match(/\.(png|jpe?g|gif|webp|svg)$/i)?"image":"file-text"),q.createSpan({cls:"af-chat-pill-name",text:B})}}let O=o.createDiv({cls:`af-thread-bubble af-thread-bubble-${x}`});return x==="assistant"?this.renderMarkdownBubble(O,_):O.setText(_),O};for(let x of s.messages)h(x.role,x.content,x.attachments);let p=[],m=[],f=i.createDiv({cls:"af-thread-composer-wrap"}),g=f.createDiv({cls:"af-chat-pills-row af-thread-pills-row"});g.setCssStyles({display:"none"});let w=()=>{if(g.empty(),p.length===0&&m.length===0){g.setCssStyles({display:"none"});return}g.setCssStyles({display:"flex"});for(let x of p){let _=g.createDiv({cls:"af-chat-pill"}),R=_.createSpan({cls:"af-chat-pill-icon"});(0,G.setIcon)(R,"file-text"),_.createSpan({cls:"af-chat-pill-name",text:x.name});let O=_.createSpan({cls:"af-chat-pill-remove"});(0,G.setIcon)(O,"x"),O.onclick=F=>{F.stopPropagation();let B=p.findIndex(q=>q.path===x.path);B>=0&&p.splice(B,1),w()}}for(let x of m){let _=g.createDiv({cls:"af-chat-pill"}),R=_.createSpan({cls:"af-chat-pill-icon"});(0,G.setIcon)(R,"image"),_.createSpan({cls:"af-chat-pill-name",text:x.name});let O=_.createSpan({cls:"af-chat-pill-remove"});(0,G.setIcon)(O,"x"),O.onclick=F=>{F.stopPropagation();let B=m.findIndex(q=>q.path===x.path);B>=0&&m.splice(B,1),w()}}},y=()=>{let x=this.app.workspace.getActiveFile();if(!x){new G.Notice("No active document to attach");return}if(p.some(R=>R.path===x.path)){new G.Notice(`"${x.name}" is already attached`);return}if(!new Set(["md","txt","json","yaml","yml","toml","csv","xml","html","css","js","ts","py","sh","sql","env","cfg","ini","log"]).has(x.extension.toLowerCase())){new G.Notice(`Can't attach "${x.name}" \u2014 only text files are supported`);return}p.push(x),w()},v=async x=>{let _=x.type.split("/")[1]?.replace("jpeg","jpg")??"png",R=x.name&&x.name!=="image"?x.name:`pasted-${Date.now()}.${_}`;if(m.some(F=>F.name===R)){new G.Notice(`"${R}" is already attached`);return}let O=await this.saveImageBlobToVault(x);O&&(m.push(O),w())},k=f.createDiv({cls:"af-chat-input-row af-thread-composer"}),b=k.createEl("button",{cls:"af-chat-attach-btn"});T(b,"plus","af-btn-icon"),b.title="Attach active document",b.onclick=x=>{x.preventDefault(),y()};let P=k.createEl("textarea",{cls:"af-chat-input af-thread-input",attr:{placeholder:"Message in thread\u2026 (Shift+Enter for newline)",rows:"1"}});P.addEventListener("paste",x=>{let _=x.clipboardData?.items;if(_)for(let R=0;R<_.length;R++){let O=_[R];if(O.type.startsWith("image/")){x.preventDefault();let F=O.getAsFile();F&&v(F);return}}}),f.addEventListener("dragover",x=>{x.preventDefault(),x.stopPropagation(),f.addClass("af-chat-input-dragover")}),f.addEventListener("dragleave",()=>{f.removeClass("af-chat-input-dragover")}),f.addEventListener("drop",x=>{x.preventDefault(),x.stopPropagation(),f.removeClass("af-chat-input-dragover");let _=x.dataTransfer?.files;if(_)for(let R=0;R<_.length;R++){let O=_[R];O.type.startsWith("image/")&&v(O)}});let A=k.createEl("button",{cls:"af-chat-send-btn"});T(A,"arrow-up","af-btn-icon"),A.setCssStyles({display:"none"});let M=()=>{P.setCssStyles({height:"auto"}),P.setCssStyles({height:`${Math.min(P.scrollHeight,120)}px`}),A.setCssStyles({display:P.value.trim()?"flex":"none"})};P.addEventListener("input",M),P.addEventListener("focus",()=>this.setStatsSource(s));let E=async()=>{let x=P.value.trim();if(!x||s.isStreaming)return;let _=await this.buildAttachmentContextFor(p,m),R=[...p.map(q=>q.name),...m.map(q=>q.name)],O=_?`${_}${x}`:void 0;P.value="",M(),p.length=0,m.length=0,w(),h("user",x,R.length>0?R:void 0),u(!0);let F=null,B="";try{await s.sendMessage(x,q=>{if(q.type==="text"){F||(u(!1),d(),F=h("assistant",""),F.empty()),B+=q.content;let oe=F.querySelector(".af-chat-stream-text");oe||(oe=F.createDiv({cls:"af-chat-stream-text"})),oe.setText(B)}else q.type==="tool_use"?d(q.toolName):q.type==="result"&&(d(),u(!1),F&&this.renderMarkdownBubble(F,Jt(B)))},O,R.length>0?R:void 0),r()}catch(q){u(!1),d();let oe=q instanceof Error?q.message:String(q);o.createDiv({cls:"af-thread-error",text:`Error: ${oe}`})}};A.onclick=()=>void E(),P.onkeydown=x=>{x.key==="Enter"&&!x.shiftKey&&!x.isComposing&&(x.preventDefault(),E())}}buildToolSummary(t,s){let r=(s??this.messagesInner).createDiv({cls:"af-chat-tool-summary"}),i=r.createEl("details"),o=i.createEl("summary"),l=new Map;for(let u of t){let h=l.get(u.name)??[];u.command&&h.push(u.command),l.set(u.name,h)}let c=o.createSpan({cls:"af-chat-tool-icon"});(0,G.setIcon)(c,"wrench"),o.appendText(` ${t.length} tool call${t.length!==1?"s":""}`);let d=i.createDiv({cls:"af-chat-tool-list"});for(let[u,h]of l){let p=h.length||(l.get(u)?.length??1),m=d.createDiv({cls:"af-chat-tool-item"}),f=p>1?`${u} (\xD7${p})`:u;m.createSpan({cls:"af-chat-tool-name",text:f}),h.length===1&&h[0]&&m.createSpan({cls:"af-chat-tool-cmd",text:h[0]})}return r}renderIndicators(t){let s=t.isStreaming,n=t.currentToolName,r=t.hasCurrentTurnText,i=!!this.messagesInner.querySelector(".af-chat-stream-text"),o=null;if(s&&n?o=`Working\u2026 (${n})`:s&&r&&!i&&(o="Replying\u2026"),o?(this.activityEl?(this.activityEl.parentElement!==this.messagesInner||this.activityEl.nextElementSibling!==null)&&this.messagesInner.appendChild(this.activityEl):this.activityEl=this.messagesInner.createDiv({cls:"af-chat-activity"}),this.activityEl.textContent!==o&&this.activityEl.setText(o)):this.activityEl&&(this.activityEl.remove(),this.activityEl=null),s&&!n&&!r)if(this.streamingDot)(this.streamingDot.parentElement!==this.messagesInner||this.streamingDot.nextElementSibling!==null)&&this.messagesInner.appendChild(this.streamingDot);else{this.streamingDot=this.messagesInner.createDiv({cls:"af-chat-streaming-dot"});for(let c=0;c<3;c++)this.streamingDot.createSpan()}else this.streamingDot&&(this.streamingDot.remove(),this.streamingDot=null);this.setAttachStopMode(s)}setAttachStopMode(t){t!==this.isInStopMode&&(this.isInStopMode=t,this.attachStopBtn.empty(),t?(T(this.attachStopBtn,"square","af-btn-icon"),this.attachStopBtn.title="Stop generation",this.attachStopBtn.addClass("af-chat-stop-mode")):(T(this.attachStopBtn,"plus","af-btn-icon"),this.attachStopBtn.title="Attach active document",this.attachStopBtn.removeClass("af-chat-stop-mode")))}handleStop(){let t=this.getCurrentSession();t&&(t.session.abort(),this.addBubble("error","Generation stopped"))}attachActiveDocument(){let t=this.app.workspace.getActiveFile();if(!t){new G.Notice("No active document to attach");return}if(this.attachedFiles.some(r=>r.path===t.path)){new G.Notice(`"${t.name}" is already attached`);return}let s=t.extension.toLowerCase();if(!new Set(["md","txt","json","yaml","yml","toml","csv","xml","html","css","js","ts","py","sh","sql","env","cfg","ini","log"]).has(s)){new G.Notice(`Can't attach "${t.name}" \u2014 only text files are supported`);return}this.attachedFiles.push(t),this.renderPills()}async saveImageBlobToVault(t){let s=t.type.split("/")[1]?.replace("jpeg","jpg")??"png",n=Date.now(),r=t.name&&t.name!=="image"?t.name:`pasted-${n}.${s}`,i=`${this.plugin.settings.fleetFolder}/chat-images`,o=`${i}/${n}-${r}`;try{this.app.vault.getAbstractFileByPath(i)||await this.app.vault.createFolder(i);let l=await t.arrayBuffer();await this.app.vault.createBinary(o,l);let d=this.app.vault.adapter.getBasePath?.()??"";return{name:r,path:`${d}/${o}`}}catch(l){let c=l instanceof Error?l.message:String(l);return new G.Notice(`Failed to save image: ${c}`),null}}async buildAttachmentContextFor(t,s){if(t.length===0&&s.length===0)return"";let n=[];for(let r of t)try{let i=await this.app.vault.cachedRead(r);n.push(`### ${r.name} \`\`\` ${i} -\`\`\``)}catch{n.push(`### ${a.name} -(Could not read file)`)}for(let a of s)n.push(`### Image: ${a.name} -The image file is located at: ${a.path} +\`\`\``)}catch{n.push(`### ${r.name} +(Could not read file)`)}for(let r of s)n.push(`### Image: ${r.name} +The image file is located at: ${r.path} Please read and analyze this image.`);return`## Attached Files ${n.join(` @@ -12051,6 +12038,6 @@ ${n.join(` --- -`}async attachImageBlob(e){let s=e.type.split("/")[1]?.replace("jpeg","jpg")??"png",n=e.name&&e.name!=="image"?e.name:`pasted-${Date.now()}.${s}`;if(this.attachedImages.some(i=>i.name===n)){new z.Notice(`"${n}" is already attached`);return}let a=await this.saveImageBlobToVault(e);a&&(this.attachedImages.push(a),this.renderPills())}removeAttachment(e){this.attachedFiles=this.attachedFiles.filter(s=>s.path!==e),this.attachedImages=this.attachedImages.filter(s=>s.path!==e),this.renderPills()}renderPills(){if(this.pillsRow.empty(),this.attachedFiles.length+this.attachedImages.length===0){this.pillsRow.setCssStyles({display:"none"});return}this.pillsRow.setCssStyles({display:"flex"});for(let s of this.attachedFiles){let n=this.pillsRow.createDiv({cls:"af-chat-pill"}),a=n.createSpan({cls:"af-chat-pill-icon"});(0,z.setIcon)(a,"file-text"),n.createSpan({cls:"af-chat-pill-name",text:s.name});let i=n.createSpan({cls:"af-chat-pill-remove"});(0,z.setIcon)(i,"x"),i.onclick=o=>{o.stopPropagation(),this.removeAttachment(s.path)}}for(let s of this.attachedImages){let n=this.pillsRow.createDiv({cls:"af-chat-pill"}),a=n.createSpan({cls:"af-chat-pill-icon"});(0,z.setIcon)(a,"image"),n.createSpan({cls:"af-chat-pill-name",text:s.name});let i=n.createSpan({cls:"af-chat-pill-remove"});(0,z.setIcon)(i,"x"),i.onclick=o=>{o.stopPropagation(),this.removeAttachment(s.path)}}}buildAttachmentContext(){return this.buildAttachmentContextFor(this.attachedFiles,this.attachedImages)}async handleSend(){let e=this.getCurrentSession();if(!e)return;let s=this.textarea.value.trim();if(!s)return;let n=await this.buildAttachmentContext(),a=[...this.attachedFiles.map(u=>u.name),...this.attachedImages.map(u=>u.name)];this.textarea.value="",this.textarea.setCssStyles({height:"auto"}),this.sendBtn.setCssStyles({display:"none"}),this.attachedFiles=[],this.attachedImages=[],this.renderPills();let i=n?`${n}${s}`:void 0;if(this.addBubble("user",s,a.length>0?a:void 0),e.session.isStreaming){e.session.injectMessage(s,i,a.length>0?a:void 0);return}let o=null,c="",l=!1,h=e.session,d=()=>this.getCurrentSession()?.session===h;try{await e.session.sendMessage(s,u=>{if(!d()){o=null,l=!1,c="";return}if(u.type==="text"){(!l||!o||!o.isConnected)&&(o=this.addBubble("assistant"),l=!0),c+=u.content;let p=o.querySelector(".af-chat-stream-text");p||(p=o.createDiv({cls:"af-chat-stream-text"})),p.setText(c)}else if(u.type==="error"){let p=u.errorMessage?.trim()||"The agent's run ended with an error.";this.addBubble("error",`Error: ${p}`)}else if(u.type!=="compacted"){if(u.type==="result"){if(l&&o&&o.isConnected){let p=At(c);this.renderMarkdownBubble(o,p),o._setRawText?.(p);let m=e.session.messages[e.session.messages.length-1];if(m&&m.role==="assistant"&&(this.attachThreadAffordance(o,m.id,e.session),u.toolCalls&&u.toolCalls.length>0)){let f=this.getOrCreateAffordancesRow(o);this.buildToolSummary(u.toolCalls,f)}}else u.toolCalls&&u.toolCalls.length>0&&this.buildToolSummary(u.toolCalls);c="",l=!1,o=null}}},i,a.length>0?a:void 0)}catch(u){let p=u instanceof Error?u.message:String(u);p!=="Aborted"&&this.addBubble("error",`Error: ${p}`)}}async handleNewChat(){if(!this.selectedAgentName)return;let s=this.plugin.runtime.getSnapshot().agents.find(a=>a.name===this.selectedAgentName);if(!s)return;let n=tl();try{await this.plugin.repository.createConversation(s,n,sl())}catch(a){new z.Notice(`Couldn't create conversation: ${a instanceof Error?a.message:String(a)}`);return}await this.switchToAgent(this.selectedAgentName,n)}startFreshIntro(e){let s="",n=null,a=!1;e.sendMessage("Please introduce yourself and briefly describe your capabilities and what you can help with.",i=>{i.type==="text"&&(a||(n=this.addBubble("assistant"),a=!0),s+=i.content,n.setText(s))}).then(i=>{a&&n?(this.renderMarkdownBubble(n,s),n._setRawText?.(s)):i.text.trim()&&(n=this.addBubble("assistant"),this.renderMarkdownBubble(n,i.text),n._setRawText?.(i.text)),i.toolCalls.length>0&&this.buildToolSummary(i.toolCalls),this.textarea.focus()}).catch(i=>{let o=i instanceof Error?i.message:String(i);o!=="Aborted"&&this.addBubble("error",`Error: ${o}`)})}};function is(r){return r>=1e3?`${(r/1e3).toFixed(r>=1e4?0:1)}k`:`${r}`}var zn=class extends Se.Plugin{settings={...it};repository;runtime;get mcpManager(){return this.runtime.mcpManager}mcpAuth=new gn;channelCredentials=new bn;channelManager;secretStore;statusBarEl;subscribedViews=new Set;vaultChangeTimer;suppressVaultEvents=!1;suppressTimer;runtimeUnsubscribe;async onload(){await this.loadSettings(),this.settings.claudeCliPath=await this.resolveClaudeCliPath(this.settings.claudeCliPath),this.repository=new ps(this.app,this.settings),this.repository.setChannelCredentialGetter(()=>this.channelCredentials.toRecord()),this.runtime=new ks(this.repository,this.settings,this.mcpAuth),this.registerView(_t,a=>new Fs(a,this)),this.registerView(jt,a=>new Un(a,this)),this.registerView(rt,a=>new os(a,this)),this.addSettingTab(new en(this)),await this.repository.ensureFleetStructure()&&await this.repository.ensureSamples();let e=await this.repository.updateDefaults(this.settings.defaultFileHashes??{});JSON.stringify(e)!==JSON.stringify(this.settings.defaultFileHashes??{})&&(this.settings.defaultFileHashes=e,await this.saveData(this.settings));let s=await this.repository.migrateUsageLedgerCosts();s&&s.changed>0&&console.log(`Agent Fleet: repaired ${s.changed} usage-ledger cost rows across ${s.files} day(s).`),await this.runtime.initialize(),await this.verifyClaudeCli(!1),await this.maybeResolveCodexCliPath(),this.addRibbonIcon("bot","Agent Fleet Dashboard",()=>void this.activateDashboardView()),this.addRibbonIcon("message-circle","Agent Chat",()=>{let a=this.app.workspace.getLeavesOfType(rt);a.length>0?this.app.workspace.revealLeaf(a[0]):this.openChatView()}),this.addCommands(),this.registerVaultHandlers(),this.registerRuntimeListeners();let n=this.app.secretStorage;if(this.secretStore=new fn(n),this.channelCredentials.setSecretStore(this.secretStore),this.mcpAuth.setSecretStore(this.secretStore),!this.settings.secretsMigrated&&this.secretStore.available){this.channelCredentials.loadCredentials(this.settings.channelCredentials??{});for(let[a,i]of Object.entries(this.settings.mcpApiKeys??{}))typeof i=="string"&&i.trim()&&this.mcpAuth.storeStaticToken(a,i);this.settings.mcpTokens={},this.settings.mcpApiKeys={},this.settings.channelCredentials={},this.settings.secretsMigrated=!0,await this.saveData(this.settings)}else this.channelCredentials.loadCredentials(this.secretStore.available?void 0:this.settings.channelCredentials??{});this.secretStore.available||this.channelCredentials.onChanged(a=>{this.settings.channelCredentials=a,this.saveSettings()}),this.channelManager=new wn({getRepository:()=>this.repository,vault:this.app.vault,getSettings:()=>this.settings,getChannelCredentials:()=>this.channelCredentials.toRecord(),getMcpAuth:()=>this.mcpAuth,recordUsage:a=>this.runtime.recordUsage(a),adapterFactory:(a,i)=>{if(a.type==="slack")return new On(a,i);if(a.type==="telegram")return new Nn(a,i);if(a.type==="discord")return new Bn(a,i);throw new Error(`Channel type \`${a.type}\` is not yet supported in this version.`)}});try{await this.channelManager.start(this.runtime.getSnapshot())}catch(a){console.error("Agent Fleet: channel manager failed to start",a),new Se.Notice("Agent Fleet: channel manager failed to start \u2014 check console.")}this.runtime.onChannelResult((a,i,o,c,l)=>{let d=`*${c==="heartbeat"?`Heartbeat \u2014 ${a}`:`${a} \u2014 ${c}`}* +`}async attachImageBlob(t){let s=t.type.split("/")[1]?.replace("jpeg","jpg")??"png",n=t.name&&t.name!=="image"?t.name:`pasted-${Date.now()}.${s}`;if(this.attachedImages.some(i=>i.name===n)){new G.Notice(`"${n}" is already attached`);return}let r=await this.saveImageBlobToVault(t);r&&(this.attachedImages.push(r),this.renderPills())}removeAttachment(t){this.attachedFiles=this.attachedFiles.filter(s=>s.path!==t),this.attachedImages=this.attachedImages.filter(s=>s.path!==t),this.renderPills()}renderPills(){if(this.pillsRow.empty(),this.attachedFiles.length+this.attachedImages.length===0){this.pillsRow.setCssStyles({display:"none"});return}this.pillsRow.setCssStyles({display:"flex"});for(let s of this.attachedFiles){let n=this.pillsRow.createDiv({cls:"af-chat-pill"}),r=n.createSpan({cls:"af-chat-pill-icon"});(0,G.setIcon)(r,"file-text"),n.createSpan({cls:"af-chat-pill-name",text:s.name});let i=n.createSpan({cls:"af-chat-pill-remove"});(0,G.setIcon)(i,"x"),i.onclick=o=>{o.stopPropagation(),this.removeAttachment(s.path)}}for(let s of this.attachedImages){let n=this.pillsRow.createDiv({cls:"af-chat-pill"}),r=n.createSpan({cls:"af-chat-pill-icon"});(0,G.setIcon)(r,"image"),n.createSpan({cls:"af-chat-pill-name",text:s.name});let i=n.createSpan({cls:"af-chat-pill-remove"});(0,G.setIcon)(i,"x"),i.onclick=o=>{o.stopPropagation(),this.removeAttachment(s.path)}}}buildAttachmentContext(){return this.buildAttachmentContextFor(this.attachedFiles,this.attachedImages)}async handleSend(){let t=this.getCurrentSession();if(!t)return;let s=this.textarea.value.trim();if(!s)return;let n=await this.buildAttachmentContext(),r=[...this.attachedFiles.map(h=>h.name),...this.attachedImages.map(h=>h.name)];this.textarea.value="",this.textarea.setCssStyles({height:"auto"}),this.sendBtn.setCssStyles({display:"none"}),this.attachedFiles=[],this.attachedImages=[],this.renderPills();let i=n?`${n}${s}`:void 0;if(this.addBubble("user",s,r.length>0?r:void 0),t.session.isStreaming){t.session.injectMessage(s,i,r.length>0?r:void 0);return}let o=null,l="",c=!1,d=t.session,u=()=>this.getCurrentSession()?.session===d;try{await t.session.sendMessage(s,h=>{if(!u()){o=null,c=!1,l="";return}if(h.type==="text"){(!c||!o||!o.isConnected)&&(o=this.addBubble("assistant"),c=!0),l+=h.content;let p=o.querySelector(".af-chat-stream-text");p||(p=o.createDiv({cls:"af-chat-stream-text"})),p.setText(l)}else if(h.type==="error"){let p=h.errorMessage?.trim()||"The agent's run ended with an error.";this.addErrorBubble(p)}else if(h.type!=="compacted"){if(h.type==="result"){if(c&&o&&o.isConnected){let p=Jt(l);this.renderMarkdownBubble(o,p),o._setRawText?.(p);let m=t.session.messages[t.session.messages.length-1];if(m&&m.role==="assistant"&&(this.attachThreadAffordance(o,m.id,t.session),h.toolCalls&&h.toolCalls.length>0)){let f=this.getOrCreateAffordancesRow(o);this.buildToolSummary(h.toolCalls,f)}}else h.toolCalls&&h.toolCalls.length>0&&this.buildToolSummary(h.toolCalls);l="",c=!1,o=null}}},i,r.length>0?r:void 0)}catch(h){let p=h instanceof Error?h.message:String(h);p!=="Aborted"&&this.addErrorBubble(p)}}addErrorBubble(t){let s=this.addBubble("error",`Error: ${t}`),n=wp(t);n&&s.createDiv({cls:"af-chat-error-hint",text:n})}async handleNewChat(){if(!this.selectedAgentName)return;let s=this.plugin.runtime.getSnapshot().agents.find(r=>r.name===this.selectedAgentName);if(!s)return;let n=Ic();try{await this.plugin.repository.createConversation(s,n,Mc())}catch(r){new G.Notice(`Couldn't create conversation: ${r instanceof Error?r.message:String(r)}`);return}await this.switchToAgent(this.selectedAgentName,n)}startFreshIntro(t){let s="",n=null,r=!1;t.sendMessage("Please introduce yourself and briefly describe your capabilities and what you can help with.",i=>{i.type==="text"&&(r||(n=this.addBubble("assistant"),r=!0),s+=i.content,n.setText(s))}).then(i=>{r&&n?(this.renderMarkdownBubble(n,s),n._setRawText?.(s)):i.text.trim()&&(n=this.addBubble("assistant"),this.renderMarkdownBubble(n,i.text),n._setRawText?.(i.text)),i.toolCalls.length>0&&this.buildToolSummary(i.toolCalls),this.textarea.focus()}).catch(i=>{let o=i instanceof Error?i.message:String(i);o!=="Aborted"&&this.addErrorBubble(o)})}};function wp(a){let e=a.toLowerCase();return/context\s*(window|length)|prompt is too long|too many tokens|token limit|maximum context|context.{0,20}exceed/.test(e)?"Try a smaller model, or start a new session to clear history.":/rate.?limit|\b429\b|overloaded|too many requests/.test(e)?"Wait a minute and retry, or check your API quota.":/\b401\b|unauthorized|api.?key|authentication|invalid.{0,10}credential|not logged in/.test(e)?"Check your API key / CLI login (run the CLI's login command in a terminal).":/timed out|time.?out|watchdog/.test(e)?"A tool may be stuck. Retry, or raise the Chat Watchdog Timeout in Settings.":/enoent|command not found|no such file or directory/.test(e)?"CLI binary not found \u2014 check the CLI path in Settings.":null}function Ds(a){return a>=1e3?`${(a/1e3).toFixed(a>=1e4?0:1)}k`:`${a}`}var Ra=class a extends pe.Plugin{settings={...ft};repository;runtime;get mcpManager(){return this.runtime.mcpManager}mcpAuth=new ea;channelCredentials=new aa;channelManager;secretStore;cliVerifiedAt=new Map;static CLI_VERIFY_TTL_MS=5*6e4;statusBarEl;subscribedViews=new Set;vaultChangeTimer;suppressVaultEvents=!1;suppressTimer;runtimeUnsubscribe;async onload(){await this.loadSettings(),this.settings.claudeCliPath=await this.resolveClaudeCliPath(this.settings.claudeCliPath),this.repository=new Us(this.app,this.settings),this.repository.setChannelCredentialGetter(()=>this.channelCredentials.toRecord()),this.runtime=new Ys(this.repository,this.settings,this.mcpAuth),this.registerView(Yt,r=>new un(r,this)),this.registerView(us,r=>new Sa(r,this)),this.registerView(mt,r=>new Rs(r,this)),this.addSettingTab(new Un(this)),await this.repository.ensureFleetStructure()&&await this.repository.ensureSamples();let t=await this.repository.updateDefaults(this.settings.defaultFileHashes??{});JSON.stringify(t)!==JSON.stringify(this.settings.defaultFileHashes??{})&&(this.settings.defaultFileHashes=t,await this.saveData(this.settings));let s=await this.repository.migrateUsageLedgerCosts();s&&s.changed>0&&console.log(`Agent Fleet: repaired ${s.changed} usage-ledger cost rows across ${s.files} day(s).`),await this.runtime.initialize(),await this.verifyClaudeCli(!1),await this.maybeResolveCodexCliPath(),this.addRibbonIcon("bot","Agent Fleet Dashboard",()=>void this.activateDashboardView()),this.addRibbonIcon("message-circle","Agent Chat",()=>{let r=this.app.workspace.getLeavesOfType(mt);r.length>0?this.app.workspace.revealLeaf(r[0]):this.openChatView()}),this.addCommands(),this.registerVaultHandlers(),this.registerRuntimeListeners();let n=this.app.secretStorage;if(this.secretStore=new Zn(n),this.channelCredentials.setSecretStore(this.secretStore),this.mcpAuth.setSecretStore(this.secretStore),!this.settings.secretsMigrated&&this.secretStore.available){this.channelCredentials.loadCredentials(this.settings.channelCredentials??{});for(let[r,i]of Object.entries(this.settings.mcpApiKeys??{}))typeof i=="string"&&i.trim()&&this.mcpAuth.storeStaticToken(r,i);this.settings.mcpTokens={},this.settings.mcpApiKeys={},this.settings.channelCredentials={},this.settings.secretsMigrated=!0,await this.saveData(this.settings)}else this.channelCredentials.loadCredentials(this.secretStore.available?void 0:this.settings.channelCredentials??{});this.secretStore.available||this.channelCredentials.onChanged(r=>{this.settings.channelCredentials=r,this.saveSettings()}),this.channelManager=new na({getRepository:()=>this.repository,vault:this.app.vault,getSettings:()=>this.settings,getChannelCredentials:()=>this.channelCredentials.toRecord(),getMcpAuth:()=>this.mcpAuth,recordUsage:r=>this.runtime.recordUsage(r),adapterFactory:(r,i)=>{if(r.type==="slack")return new ba(r,i);if(r.type==="telegram")return new ka(r,i);if(r.type==="discord")return new xa(r,i);throw new Error(`Channel type \`${r.type}\` is not yet supported in this version.`)}});try{await this.channelManager.start(this.runtime.getSnapshot())}catch(r){console.error("Agent Fleet: channel manager failed to start",r),new pe.Notice("Agent Fleet: channel manager failed to start \u2014 check console.")}this.runtime.onChannelResult((r,i,o,l,c)=>{let u=`*${l==="heartbeat"?`Heartbeat \u2014 ${r}`:`${r} \u2014 ${l}`}* -${o}`;(l?this.channelManager?.postToChannelTarget(i,l,d):this.channelManager?.broadcastToChannel(i,d))?.catch(p=>{console.warn(`Agent Fleet: channel post failed for ${a}`,p)})}),this.refreshStatusBar(),this.importNativeMcpServers(),this.registerInterval(window.setInterval(()=>void this.mcpManager.refreshProbeTokens(),30*6e4)),new Se.Notice("Agent Fleet loaded.")}async importNativeMcpServers(){if(this.settings.mcpImported)return;if(this.repository.getMcpServers().length>0){this.settings.mcpImported=!0,await this.saveData(this.settings);return}let t=n=>{try{return(0,Os.existsSync)(n)?(0,Os.readFileSync)(n,"utf-8"):null}catch{return null}},e=t((0,Qa.join)((0,Xa.homedir)(),".claude.json")),s=t((0,Qa.join)((0,Xa.homedir)(),".codex","config.toml"));try{let n=_i(e?Ci(e):{servers:[],tokens:{}},s?Ti(s):{servers:[],tokens:{}}),a=0;for(let i of n.servers)try{await this.repository.saveMcpServer(i,i.description??"");let o=n.tokens[i.name];o&&this.mcpAuth.storeStaticToken(i.name,o),a++}catch(o){console.warn(`Agent Fleet: failed to import MCP server "${i.name}":`,o)}this.settings.mcpImported=!0,await this.saveData(this.settings),a>0&&(await this.refreshFromVault(),new Se.Notice(`Agent Fleet: imported ${a} MCP server${a===1?"":"s"}.`))}catch(n){console.error("Agent Fleet: MCP import failed",n)}}onunload(){this.runtimeUnsubscribe?.(),this.runtimeUnsubscribe=void 0,this.vaultChangeTimer&&(window.clearTimeout(this.vaultChangeTimer),this.vaultChangeTimer=void 0),this.suppressTimer&&(window.clearTimeout(this.suppressTimer),this.suppressTimer=void 0),this.runtime?.shutdown(),this.channelManager?.stop(),Zr()}async loadSettings(){this.settings={...it,...await this.loadData()}}async saveSettings(){this.settings.claudeCliPath=await this.resolveClaudeCliPath(this.settings.claudeCliPath),await this.maybeResolveCodexCliPath(),ei(),await this.saveData(this.settings),this.repository&&this.runtime&&(this.runtime.shutdown(),this.repository=new ps(this.app,this.settings),this.repository.setChannelCredentialGetter(()=>this.channelCredentials.toRecord()),this.runtime=new ks(this.repository,this.settings,this.mcpAuth),await this.repository.ensureFleetStructure(),await this.runtime.initialize(),this.registerRuntimeListeners(),this.notifyViews(),this.refreshStatusBar(),this.channelCredentials.loadCredentials(this.secretStore.available?void 0:this.settings.channelCredentials??{}),this.channelManager?.reconcile(this.runtime.getSnapshot()))}subscribeView(t){this.subscribedViews.add(t)}unsubscribeView(t){this.subscribedViews.delete(t)}async activateDashboardView(){let t=this.app.workspace.getLeavesOfType(_t);if(t.length>0){this.app.workspace.revealLeaf(t[0]);return}await this.app.workspace.getLeaf(!0).setViewState({type:_t,active:!0})}async navigateDashboard(t,e){await this.activateDashboardView();let n=this.app.workspace.getLeavesOfType(_t)[0];if(n){let a=n.view;a instanceof Fs&&a.navigateTo(t,e)}}async activateAgentsView(){let t=this.getLeafForView(jt,"left");await t.setViewState({type:jt,active:!0}),this.app.workspace.revealLeaf(t)}async openChatView(t){if(t){let s=this.app.workspace.getLeavesOfType(rt);for(let n of s)if(n.view instanceof os&&n.view.selectedAgentName===t){this.app.workspace.revealLeaf(n);return}}let e=this.app.workspace.getRightLeaf(!1)??this.app.workspace.getLeaf(!0);await e.setViewState({type:rt,active:!0,state:t?{agentName:t}:{}}),this.app.workspace.revealLeaf(e),t&&e.view instanceof os&&e.view.selectAgent(t)}async refreshFromVault(){this.suppressVaultEvents=!0;try{await this.runtime.refreshFromVault(),this.notifyViews(),this.refreshStatusBar(),this.channelManager?.reconcile(this.runtime.getSnapshot())}finally{this.suppressTimer&&window.clearTimeout(this.suppressTimer),this.suppressTimer=window.setTimeout(()=>{this.suppressTimer=void 0,this.suppressVaultEvents=!1},500)}}refreshStatusBar(){if(!this.settings.showStatusBar){this.statusBarEl?.detach(),this.statusBarEl=void 0;return}this.statusBarEl||(this.statusBarEl=this.addStatusBarItem(),this.statusBarEl.onclick=()=>void this.activateDashboardView());let t=this.runtime.getFleetStatus();this.statusBarEl.setText(`\u{1F916} ${t.running} running \xB7 ${t.pending} pending \xB7 ${t.completedToday} completed today`)}async verifyClaudeCli(t=!0){let e=await this.resolveClaudeCliPath(this.settings.claudeCliPath);return this.settings.claudeCliPath=e,await this.verifyCliBinary(e,"Claude",t)}async verifyCodexCli(t=!0){let e=await this.resolveCliPathFrom(ta(this.settings.codexCliPath),this.settings.codexCliPath);return this.settings.codexCliPath=e,await this.verifyCliBinary(e,"Codex",t)}async verifyCliBinary(t,e,s){return await new Promise(n=>{let a=dt(t,["--version"]),i="";a.stderr.on("data",o=>{i+=o.toString()}),a.on("close",o=>{let c=o===0;c||console.error(`Agent Fleet: ${e} CLI verification failed`,i),s&&new Se.Notice(c?`${e} CLI available.`:`${e} CLI verification failed \u2014 check ${e} CLI Path in settings.`),n(c)}),a.on("error",o=>{console.error(`Agent Fleet: ${e} CLI verification error`,o),s&&new Se.Notice(`${e} CLI verification failed \u2014 check ${e} CLI Path in settings.`),n(!1)})})}async openPath(t){let e=this.app.vault.getAbstractFileByPath((0,Se.normalizePath)(t));e instanceof Se.TFile&&await this.app.workspace.getLeaf(!0).openFile(e)}async createAgentTemplate(){await this.navigateDashboard("create-agent")}async createSkillTemplate(){await this.navigateDashboard("create-skill")}async openCreateTask(){await this.navigateDashboard("create-task")}async runAgentPrompt(t){let e=this.repository.getAgentByName(t);if(!e){new Se.Notice(`Unknown agent: ${t}`);return}await this.runtime.runAgentNow(e,"Run now and summarize the current state.")}async chatWithAgent(t){if(!this.repository.getAgentByName(t)){new Se.Notice(`Unknown agent: ${t}`);return}await this.openChatView(t)}async deleteAgent(t){if(!this.repository.getAgentByName(t)){new Se.Notice(`Unknown agent: ${t}`);return}let s=this.repository.getTasksForAgent(t),n=this.runtime.getRecentRuns().filter(o=>o.agent===t),a=this.repository.getMemoryPath(t),i=!!this.app.vault.getAbstractFileByPath(a);new Ks(this.app,{agentName:t,taskCount:s.length,runCount:n.length,hasMemory:i},async o=>{let c=await this.repository.deleteAgent(t,o);await new Promise(l=>window.setTimeout(l,200)),await this.refreshFromVault(),new Se.Notice(`Deleted agent "${t}" (${c.trashedFiles.length} files moved to trash)`),await this.navigateDashboard("agents")}).open()}async toggleAgent(t,e){let s=this.repository.getAgentByName(t);if(!s)return;let n=this.app.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof Se.TFile))return;let a=await this.app.vault.cachedRead(n),{frontmatter:i,body:o}=J(a);i.enabled=e,await this.app.vault.modify(n,H(i,o)),await this.refreshFromVault()}addCommands(){this.addCommand({id:"open-dashboard",name:"Open dashboard",callback:()=>void this.activateDashboardView()}),this.addCommand({id:"open-agents-panel",name:"Open agents panel",callback:()=>void this.activateAgentsView()}),this.addCommand({id:"open-chat",name:"Open agent chat",callback:()=>{let t=this.app.workspace.getLeavesOfType(rt);t.length>0?this.app.workspace.revealLeaf(t[0]):this.openChatView()}}),this.addCommand({id:"new-chat-tab",name:"New chat tab",callback:()=>void this.openChatView()}),this.addCommand({id:"new-agent",name:"New agent",callback:()=>void this.createAgentTemplate()}),this.addCommand({id:"new-skill",name:"New skill",callback:()=>void this.createSkillTemplate()}),this.addCommand({id:"new-task",name:"New task",callback:()=>void this.openCreateTask()}),this.addCommand({id:"run-agent-now",name:"Run agent now",callback:()=>{let t=this.runtime.getSnapshot().agents[0];t?this.runAgentPrompt(t.name):new Se.Notice("No agents configured.")}}),this.addCommand({id:"pause-all",name:"Pause all",callback:()=>{this.runtime.scheduler.pauseAll(),new Se.Notice("Agent Fleet paused.")}}),this.addCommand({id:"resume-all",name:"Resume all",callback:()=>{this.runtime.scheduler.resumeAll(),new Se.Notice("Agent Fleet resumed.")}}),this.addCommand({id:"view-fleet-status",name:"View status",callback:()=>{let t=this.runtime.getFleetStatus();new Se.Notice(`${t.running} running \xB7 ${t.pending} pending \xB7 ${t.completedToday} completed today`)}})}debouncedVaultRefresh(){this.suppressVaultEvents||(this.vaultChangeTimer&&window.clearTimeout(this.vaultChangeTimer),this.vaultChangeTimer=window.setTimeout(()=>{this.suppressVaultEvents||this.refreshFromVault()},500))}registerVaultHandlers(){let t=e=>e.startsWith(`${this.settings.fleetFolder}/usage/`);this.registerEvent(this.app.vault.on("create",e=>{e instanceof Se.TFile&&e.path.startsWith(`${this.settings.fleetFolder}/`)&&!t(e.path)&&this.debouncedVaultRefresh()})),this.registerEvent(this.app.vault.on("modify",e=>{e instanceof Se.TFile&&e.path.startsWith(`${this.settings.fleetFolder}/`)&&!t(e.path)&&this.debouncedVaultRefresh()})),this.registerEvent(this.app.vault.on("rename",e=>{e.path.startsWith(`${this.settings.fleetFolder}/`)&&this.debouncedVaultRefresh()})),this.registerEvent(this.app.vault.on("delete",e=>{e.path.startsWith(`${this.settings.fleetFolder}/`)&&this.debouncedVaultRefresh()}))}registerRuntimeListeners(){this.runtimeUnsubscribe?.(),this.runtimeUnsubscribe=this.runtime.subscribe(()=>{this.notifyViews(),this.refreshStatusBar()})}notifyViews(){for(let t of this.subscribedViews)t.render()}async resolveClaudeCliPath(t){return this.resolveCliPathFrom(Tr(t),t)}async maybeResolveCodexCliPath(){this.runtime?.getSnapshot().agents.some(e=>kt(e.adapter)==="codex")&&(this.settings.codexCliPath=await this.resolveCliPathFrom(ta(this.settings.codexCliPath),this.settings.codexCliPath))}async resolveCliPathFrom(t,e){for(let s of t)if(sa(s)&&(0,Os.existsSync)(s)||!sa(s)&&await new Promise(a=>{let i=dt(s,["--version"]);i.on("close",o=>a(o===0)),i.on("error",()=>a(!1))}))return s;return e}getLeafForView(t,e){let s=this.app.workspace.getLeavesOfType(t)[0];return s||(e==="right"?this.app.workspace.getRightLeaf(!1)??this.app.workspace.getLeaf(!0):this.app.workspace.getLeftLeaf(!1)??this.app.workspace.getLeaf(!1))}}; +${o}`;(c?this.channelManager?.postToChannelTarget(i,c,u):this.channelManager?.broadcastToChannel(i,u))?.catch(p=>{console.warn(`Agent Fleet: channel post failed for ${r}`,p)})}),this.refreshStatusBar(),this.importNativeMcpServers(),this.cleanupStaleTempFiles().catch(r=>{console.warn("Agent Fleet: stale temp-file sweep failed",r)}),this.registerInterval(window.setInterval(()=>void this.mcpManager.refreshProbeTokens(),30*6e4)),new pe.Notice("Agent Fleet loaded.")}async importNativeMcpServers(){if(this.settings.mcpImported)return;if(this.repository.getMcpServers().length>0){this.settings.mcpImported=!0,await this.saveData(this.settings);return}let e=n=>{try{return(0,hn.existsSync)(n)?(0,hn.readFileSync)(n,"utf-8"):null}catch{return null}},t=e((0,Is.join)((0,pn.homedir)(),".claude.json")),s=e((0,Is.join)((0,pn.homedir)(),".codex","config.toml"));try{let n=Eo(t?Ao(t):{servers:[],tokens:{}},s?Po(s):{servers:[],tokens:{}}),r=0;for(let i of n.servers)try{await this.repository.saveMcpServer(i,i.description??"");let o=n.tokens[i.name];o&&this.mcpAuth.storeStaticToken(i.name,o),r++}catch(o){console.warn(`Agent Fleet: failed to import MCP server "${i.name}":`,o)}this.settings.mcpImported=!0,await this.saveData(this.settings),r>0&&(await this.refreshFromVault(),new pe.Notice(`Agent Fleet: imported ${r} MCP server${r===1?"":"s"}.`))}catch(n){console.error("Agent Fleet: MCP import failed",n)}}onunload(){this.runtimeUnsubscribe?.(),this.runtimeUnsubscribe=void 0,this.vaultChangeTimer&&(window.clearTimeout(this.vaultChangeTimer),this.vaultChangeTimer=void 0),this.suppressTimer&&(window.clearTimeout(this.suppressTimer),this.suppressTimer=void 0),this.runtime?.shutdown(),this.channelManager?.stop(),Xi()}async loadSettings(){this.settings={...ft,...await this.loadData()}}async saveSettings(){this.settings.claudeCliPath=await this.resolveClaudeCliPath(this.settings.claudeCliPath),await this.maybeResolveCodexCliPath(),Qi(),await this.saveData(this.settings),this.repository&&this.runtime&&(this.runtime.shutdown(),this.repository=new Us(this.app,this.settings),this.repository.setChannelCredentialGetter(()=>this.channelCredentials.toRecord()),this.runtime=new Ys(this.repository,this.settings,this.mcpAuth),await this.repository.ensureFleetStructure(),await this.runtime.initialize(),this.registerRuntimeListeners(),this.notifyViews(),this.refreshStatusBar(),this.channelCredentials.loadCredentials(this.secretStore.available?void 0:this.settings.channelCredentials??{}),this.channelManager?.reconcile(this.runtime.getSnapshot()))}subscribeView(e){this.subscribedViews.add(e)}unsubscribeView(e){this.subscribedViews.delete(e)}async activateDashboardView(){let e=this.app.workspace.getLeavesOfType(Yt);if(e.length>0){this.app.workspace.revealLeaf(e[0]);return}await this.app.workspace.getLeaf(!0).setViewState({type:Yt,active:!0})}async navigateDashboard(e,t){await this.activateDashboardView();let n=this.app.workspace.getLeavesOfType(Yt)[0];if(n){let r=n.view;r instanceof un&&r.navigateTo(e,t)}}async activateAgentsView(){let e=this.getLeafForView(us,"left");await e.setViewState({type:us,active:!0}),this.app.workspace.revealLeaf(e)}async openChatView(e){if(e){let s=this.app.workspace.getLeavesOfType(mt);for(let n of s)if(n.view instanceof Rs&&n.view.selectedAgentName===e){this.app.workspace.revealLeaf(n);return}}let t=this.app.workspace.getRightLeaf(!1)??this.app.workspace.getLeaf(!0);await t.setViewState({type:mt,active:!0,state:e?{agentName:e}:{}}),this.app.workspace.revealLeaf(t),e&&t.view instanceof Rs&&t.view.selectAgent(e)}async refreshFromVault(){this.suppressVaultEvents=!0;try{await this.runtime.refreshFromVault(),this.notifyViews(),this.refreshStatusBar(),this.channelManager?.reconcile(this.runtime.getSnapshot())}finally{this.suppressTimer&&window.clearTimeout(this.suppressTimer),this.suppressTimer=window.setTimeout(()=>{this.suppressTimer=void 0,this.suppressVaultEvents=!1},500)}}refreshStatusBar(){if(!this.settings.showStatusBar){this.statusBarEl?.detach(),this.statusBarEl=void 0;return}this.statusBarEl||(this.statusBarEl=this.addStatusBarItem(),this.statusBarEl.onclick=()=>void this.activateDashboardView());let e=this.runtime.getFleetStatus();this.statusBarEl.setText(`\u{1F916} ${e.running} running \xB7 ${e.pending} pending \xB7 ${e.completedToday} completed today`)}async verifyClaudeCli(e=!0){let t=await this.resolveClaudeCliPath(this.settings.claudeCliPath);return this.settings.claudeCliPath=t,await this.verifyCliBinary(t,"Claude",e)}async verifyCodexCli(e=!0){let t=await this.resolveCliPathFrom(za(this.settings.codexCliPath),this.settings.codexCliPath);return this.settings.codexCliPath=t,await this.verifyCliBinary(t,"Codex",e)}async verifyCliBinary(e,t,s){let n=`${t}:${e}`,r=this.cliVerifiedAt.get(n);if(r!==void 0&&Date.now()-r{let c=wt(e,["--version"]),d="";c.stderr.on("data",u=>{d+=u.toString()}),c.on("close",u=>{let h=u===0;h?this.cliVerifiedAt.set(n,Date.now()):console.error(`Agent Fleet: ${t} CLI verification failed`,d),s&&new pe.Notice(h?`${t} CLI available.`:o,h?5e3:1e4),l(h)}),c.on("error",u=>{console.error(`Agent Fleet: ${t} CLI verification error`,u),s&&new pe.Notice(o,1e4),l(!1)})})}async cleanupStaleTempFiles(){let e=Date.now(),t=async(n,r,i)=>{let o;try{o=await(0,Ms.readdir)(n)}catch{return}for(let l of o){if(!r(l))continue;let c=(0,Is.join)(n,l);try{let d=await(0,Ms.stat)(c);e-d.mtimeMs>i&&await(0,Ms.rm)(c,{recursive:!0,force:!0})}catch{}}},s=this.repository.getVaultBasePath();s&&await t((0,Is.join)(s,".claude"),n=>/^(af-mcp|af-remember-mcp)[.-].+\.(json|cjs)$/.test(n),1440*60*1e3),await t((0,Is.join)((0,pn.tmpdir)(),"agent-fleet-codex"),()=>!0,10080*60*1e3)}async openPath(e){let t=this.app.vault.getAbstractFileByPath((0,pe.normalizePath)(e));t instanceof pe.TFile&&await this.app.workspace.getLeaf(!0).openFile(t)}async createAgentTemplate(){await this.navigateDashboard("create-agent")}async createSkillTemplate(){await this.navigateDashboard("create-skill")}async openCreateTask(){await this.navigateDashboard("create-task")}async runAgentPrompt(e){let t=this.repository.getAgentByName(e);if(!t){new pe.Notice(`Unknown agent: ${e}`);return}await this.runtime.runAgentNow(t,"Run now and summarize the current state.")}async chatWithAgent(e){if(!this.repository.getAgentByName(e)){new pe.Notice(`Unknown agent: ${e}`);return}await this.openChatView(e)}async deleteAgent(e){if(!this.repository.getAgentByName(e)){new pe.Notice(`Unknown agent: ${e}`);return}let s=this.repository.getTasksForAgent(e),n=this.runtime.getRecentRuns().filter(o=>o.agent===e),r=this.repository.getMemoryPath(e),i=!!this.app.vault.getAbstractFileByPath(r);new Fn(this.app,{agentName:e,taskCount:s.length,runCount:n.length,hasMemory:i},async o=>{let l=await this.repository.deleteAgent(e,o);await new Promise(c=>window.setTimeout(c,200)),await this.refreshFromVault(),new pe.Notice(`Deleted agent "${e}" (${l.trashedFiles.length} files moved to trash)`),await this.navigateDashboard("agents")}).open()}async toggleAgent(e,t){let s=this.repository.getAgentByName(e);if(!s)return;let n=this.app.vault.getAbstractFileByPath(s.filePath);if(!(n instanceof pe.TFile))return;let r=await this.app.vault.cachedRead(n),{frontmatter:i,body:o}=W(r);i.enabled=t,await this.app.vault.modify(n,U(i,o)),await this.refreshFromVault()}addCommands(){this.addCommand({id:"open-dashboard",name:"Open dashboard",callback:()=>void this.activateDashboardView()}),this.addCommand({id:"open-agents-panel",name:"Open agents panel",callback:()=>void this.activateAgentsView()}),this.addCommand({id:"open-chat",name:"Open agent chat",callback:()=>{let e=this.app.workspace.getLeavesOfType(mt);e.length>0?this.app.workspace.revealLeaf(e[0]):this.openChatView()}}),this.addCommand({id:"new-chat-tab",name:"New chat tab",callback:()=>void this.openChatView()}),this.addCommand({id:"new-agent",name:"New agent",callback:()=>void this.createAgentTemplate()}),this.addCommand({id:"new-skill",name:"New skill",callback:()=>void this.createSkillTemplate()}),this.addCommand({id:"new-task",name:"New task",callback:()=>void this.openCreateTask()}),this.addCommand({id:"run-agent-now",name:"Run agent now",callback:()=>{let e=this.runtime.getSnapshot().agents[0];e?this.runAgentPrompt(e.name):new pe.Notice("No agents configured.")}}),this.addCommand({id:"pause-all",name:"Pause all",callback:()=>{this.runtime.scheduler.pauseAll(),new pe.Notice("Agent Fleet paused.")}}),this.addCommand({id:"resume-all",name:"Resume all",callback:()=>{this.runtime.scheduler.resumeAll(),new pe.Notice("Agent Fleet resumed.")}}),this.addCommand({id:"view-fleet-status",name:"View status",callback:()=>{let e=this.runtime.getFleetStatus();new pe.Notice(`${e.running} running \xB7 ${e.pending} pending \xB7 ${e.completedToday} completed today`)}})}debouncedVaultRefresh(){this.suppressVaultEvents||(this.vaultChangeTimer&&window.clearTimeout(this.vaultChangeTimer),this.vaultChangeTimer=window.setTimeout(()=>{this.suppressVaultEvents||this.refreshFromVault()},500))}registerVaultHandlers(){let e=t=>t.startsWith(`${this.settings.fleetFolder}/usage/`);this.registerEvent(this.app.vault.on("create",t=>{t instanceof pe.TFile&&t.path.startsWith(`${this.settings.fleetFolder}/`)&&!e(t.path)&&this.debouncedVaultRefresh()})),this.registerEvent(this.app.vault.on("modify",t=>{t instanceof pe.TFile&&t.path.startsWith(`${this.settings.fleetFolder}/`)&&!e(t.path)&&this.debouncedVaultRefresh()})),this.registerEvent(this.app.vault.on("rename",t=>{t.path.startsWith(`${this.settings.fleetFolder}/`)&&this.debouncedVaultRefresh()})),this.registerEvent(this.app.vault.on("delete",t=>{t.path.startsWith(`${this.settings.fleetFolder}/`)&&this.debouncedVaultRefresh()}))}registerRuntimeListeners(){this.runtimeUnsubscribe?.(),this.runtimeUnsubscribe=this.runtime.subscribe(()=>{this.notifyViews(),this.refreshStatusBar()})}notifyViews(){for(let e of this.subscribedViews)e.render()}async resolveClaudeCliPath(e){return this.resolveCliPathFrom(Ai(e),e)}async maybeResolveCodexCliPath(){this.runtime?.getSnapshot().agents.some(t=>Bt(t.adapter)==="codex")&&(this.settings.codexCliPath=await this.resolveCliPathFrom(za(this.settings.codexCliPath),this.settings.codexCliPath))}async resolveCliPathFrom(e,t){for(let s of e)if(Ga(s)&&(0,hn.existsSync)(s)||!Ga(s)&&await new Promise(r=>{let i=wt(s,["--version"]);i.on("close",o=>r(o===0)),i.on("error",()=>r(!1))}))return s;return t}getLeafForView(e,t){let s=this.app.workspace.getLeavesOfType(e)[0];return s||(t==="right"?this.app.workspace.getRightLeaf(!1)??this.app.workspace.getLeaf(!0):this.app.workspace.getLeftLeaf(!1)??this.app.workspace.getLeaf(!1))}}; diff --git a/manifest.json b/manifest.json index 4c38de8..5873440 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "agent-fleet", "name": "Agent Fleet", - "version": "0.15.0", + "version": "0.16.0", "minAppVersion": "1.11.4", - "description": "File-backed AI agents with task scheduling, channels, memory, and MCP — running on Claude Code or OpenAI Codex, all as plain markdown.", + "description": "File-backed AI agents with task scheduling, channels, memory, and MCP \u2014 running on Claude Code or OpenAI Codex, all as plain markdown.", "author": "Denis Berekchiyan", "authorUrl": "https://github.com/denberek", "isDesktopOnly": true diff --git a/package-lock.json b/package-lock.json index e085a28..5c616dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-agent-fleet", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-agent-fleet", - "version": "0.15.0", + "version": "0.16.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index dc02b6c..a79173a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-agent-fleet", - "version": "0.15.0", + "version": "0.16.0", "description": "Obsidian plugin for file-backed AI agents, task scheduling, channels (Slack, Telegram, Discord), heartbeat, and interactive chat.", "license": "MIT", "main": "plugin/main.js", diff --git a/src/adapters/claudeCodeAdapter.test.ts b/src/adapters/claudeCodeAdapter.test.ts index ab2ee95..8f93f2b 100644 --- a/src/adapters/claudeCodeAdapter.test.ts +++ b/src/adapters/claudeCodeAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentConfig, FleetSettings } from "../types"; import type { ExecBuildOptions } from "./types"; import { claudeCodeAdapter, isCodexShapedModel } from "./claudeCodeAdapter"; @@ -151,6 +151,49 @@ describe("claudeCodeAdapter.parseExecOutput", () => { const parsed = claudeCodeAdapter.parseExecOutput("", "spawn failed", true); expect(parsed.outputText).toBe("spawn failed"); }); + + describe("parse-failure logging", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not warn about non-JSON noise between valid stream events", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const stdout = [ + "Some CLI banner line", + JSON.stringify({ type: "result", result: "Done.", usage: { input_tokens: 1, output_tokens: 1 } }), + ].join("\n"); + const parsed = claudeCodeAdapter.parseExecOutput(stdout, "", true); + expect(parsed.outputText).toBe("Done."); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns when a streaming run produced no parseable JSON event at all", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const parsed = claudeCodeAdapter.parseExecOutput("total garbage\nno json here", "", true); + expect(parsed.outputText).toBe("(no output)"); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("no parseable JSON event"); + expect(message).toContain("total garbage"); + }); + + it("warns when non-streaming whole-stdout JSON fails to parse", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const parsed = claudeCodeAdapter.parseExecOutput('{"result": "truncat', "", false); + expect(parsed.outputText).toBe("(no output)"); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("failed to parse"); + expect(message).toContain('{"result": "truncat'); + }); + + it("does not warn on empty stdout", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + claudeCodeAdapter.parseExecOutput("", "spawn failed", true); + expect(warn).not.toHaveBeenCalled(); + }); + }); }); describe("isCodexShapedModel", () => { diff --git a/src/adapters/claudeCodeAdapter.ts b/src/adapters/claudeCodeAdapter.ts index 9a5e720..0477168 100644 --- a/src/adapters/claudeCodeAdapter.ts +++ b/src/adapters/claudeCodeAdapter.ts @@ -1,6 +1,7 @@ import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types"; import { splitLines } from "../utils/platform"; import { restoreClaudeSettingsFile, writeClaudeSettingsFile } from "../utils/claudeSettings"; +import { parseJsonLoud, tryParseJson, warnJsonParseFailure } from "./parseHelpers"; import type { CliAdapter, ExecBuildOptions, @@ -263,25 +264,25 @@ export const claudeCodeAdapter: CliAdapter = { let rawJson: unknown; if (streaming) { - // stream-json: find the last parseable event (normally the "result" line) + // stream-json: find the last parseable event (normally the "result" + // line). Individual non-JSON lines are expected (banners, verbose + // noise) — but a stream with NO parseable event at all is a real + // failure worth surfacing. const lines = splitLines(trimmed); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]?.trim(); if (!line) continue; - try { - const parsed: unknown = JSON.parse(line); - if (parsed && typeof parsed === "object") { - rawJson = parsed; - break; - } - } catch { /* skip non-json lines */ } + const parsed = tryParseJson(line); + if (parsed && typeof parsed === "object") { + rawJson = parsed; + break; + } + } + if (rawJson === undefined && trimmed) { + warnJsonParseFailure("Claude Code stream-json output contained no parseable JSON event", trimmed); } } else if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - rawJson = JSON.parse(trimmed); - } catch { - rawJson = undefined; - } + rawJson = parseJsonLoud("Claude Code JSON output failed to parse", trimmed); } let outputText = extractText(rawJson) ?? ""; @@ -291,17 +292,16 @@ export const claudeCodeAdapter: CliAdapter = { for (const line of splitLines(trimmed)) { const l = line.trim(); if (!l) continue; - try { - const ev = JSON.parse(l) as ClaudeStreamEvent; - // Only extract assistant text content, skip system/result/user events - if (ev.type === "assistant" && ev.message?.content) { - for (const block of ev.message.content) { - if (block.type === "text" && block.text) textParts.push(block.text); - } - } else if (ev.type === "result" && typeof ev.result === "string") { - textParts.push(ev.result); + const ev = tryParseJson(l) as ClaudeStreamEvent | undefined; + if (!ev || typeof ev !== "object") continue; // non-JSON noise is expected + // Only extract assistant text content, skip system/result/user events + if (ev.type === "assistant" && ev.message?.content) { + for (const block of ev.message.content) { + if (block.type === "text" && block.text) textParts.push(block.text); } - } catch { /* not JSON, skip */ } + } else if (ev.type === "result" && typeof ev.result === "string") { + textParts.push(ev.result); + } } outputText = textParts.join("\n").trim(); } @@ -316,18 +316,17 @@ export const claudeCodeAdapter: CliAdapter = { for (const line of splitLines(trimmed)) { const l = line.trim(); if (!l) continue; - try { - const ev: unknown = JSON.parse(l); - if (!concreteModel) { - const m = extractConcreteModel(ev); - if (m) concreteModel = m; - } - if (!finalResult) { - const r = extractFinalResult(ev); - if (r) finalResult = r; - } - if (concreteModel && finalResult) break; - } catch { /* not JSON, skip */ } + const ev = tryParseJson(l); // non-JSON noise is expected, skip silently + if (ev === undefined) continue; + if (!concreteModel) { + const m = extractConcreteModel(ev); + if (m) concreteModel = m; + } + if (!finalResult) { + const r = extractFinalResult(ev); + if (r) finalResult = r; + } + if (concreteModel && finalResult) break; } } @@ -345,12 +344,10 @@ export const claudeCodeAdapter: CliAdapter = { extractStreamChunk(line: string): string | null { const trimmed = line.trim(); if (!trimmed) return null; - let event: Record; - try { - event = JSON.parse(trimmed) as Record; - } catch { - return null; - } + // Live per-line parsing — non-JSON lines are expected noise, skip silently. + const parsed = tryParseJson(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const event = parsed as Record; const type = event.type as string | undefined; // Assistant message: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} diff --git a/src/adapters/codexAdapter.test.ts b/src/adapters/codexAdapter.test.ts index 07c5712..ab0b23e 100644 --- a/src/adapters/codexAdapter.test.ts +++ b/src/adapters/codexAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentConfig, FleetSettings } from "../types"; import type { ExecBuildOptions } from "./types"; import { @@ -230,6 +230,39 @@ describe("codexAdapter.parseExecOutput", () => { const parsed = codexAdapter.parseExecOutput("", "codex: command failed", true); expect(parsed.outputText).toBe("codex: command failed"); }); + + describe("parse-failure logging", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not warn about non-JSON noise mixed with valid JSONL events", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const stdout = [ + "codex banner text", + JSON.stringify({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } }), + ].join("\n"); + const parsed = codexAdapter.parseExecOutput(stdout, "", true); + expect(parsed.outputText).toBe("hi"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns when non-empty stdout contained no parseable JSONL event", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const parsed = codexAdapter.parseExecOutput("plain text output\nstill not json", "", true); + expect(parsed.outputText).toBe("(no output)"); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("no parseable JSONL event"); + expect(message).toContain("plain text output"); + }); + + it("does not warn on empty stdout", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + codexAdapter.parseExecOutput("", "codex: command failed", true); + expect(warn).not.toHaveBeenCalled(); + }); + }); }); describe("codexAdapter.extractStreamChunk", () => { diff --git a/src/adapters/codexAdapter.ts b/src/adapters/codexAdapter.ts index 4aa5cd7..c271f88 100644 --- a/src/adapters/codexAdapter.ts +++ b/src/adapters/codexAdapter.ts @@ -1,6 +1,7 @@ import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types"; import { splitLines } from "../utils/platform"; import { setupCodexPermissions } from "./codexPermissions"; +import { tryParseJson, warnJsonParseFailure } from "./parseHelpers"; import type { CliAdapter, ExecBuildOptions, @@ -125,17 +126,16 @@ export function buildCodexExecArgs(opts: ExecBuildOptions): { args: string[]; st type JsonRecord = Record; +/** Parse one JSONL line into an object event, or null. Non-JSON lines are + * expected noise (CLI banners, progress text) and are skipped silently — + * parseExecOutput warns separately when NO line parsed at all. */ function parseJsonLine(line: string): JsonRecord | null { const trimmed = line.trim(); if (!trimmed) return null; - try { - const parsed: unknown = JSON.parse(trimmed); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as JsonRecord) - : null; - } catch { - return null; - } + const parsed = tryParseJson(trimmed); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as JsonRecord) + : null; } function itemOf(event: JsonRecord): JsonRecord | null { @@ -311,10 +311,12 @@ export const codexAdapter: CliAdapter = { let totalTokens = 0; let sessionId: string | undefined; let lastTurnCompleted: JsonRecord | undefined; + let parsedAnyEvent = false; for (const line of splitLines(stdout)) { const event = parseJsonLine(line); if (!event) continue; + parsedAnyEvent = true; const type = typeof event.type === "string" ? event.type : ""; if (type === "thread.started" && typeof event.thread_id === "string") { @@ -342,6 +344,13 @@ export const codexAdapter: CliAdapter = { } } + // Non-JSON lines between events are expected; a non-empty stdout with NO + // parseable JSONL event at all means the CLI's output format drifted (or + // --json was ignored) — surface that instead of a silent "(no output)". + if (!parsedAnyEvent && stdout.trim()) { + warnJsonParseFailure("Codex exec output contained no parseable JSONL event", stdout.trim()); + } + let outputText = agentMessages.join("\n\n").trim(); if (!outputText) outputText = errors.join("\n").trim(); if (!outputText) outputText = stderr.trim() || "(no output)"; diff --git a/src/adapters/codexPermissions.ts b/src/adapters/codexPermissions.ts index ddd4726..539179e 100644 --- a/src/adapters/codexPermissions.ts +++ b/src/adapters/codexPermissions.ts @@ -239,8 +239,14 @@ export function buildCodexHomeOverlay( if (!f.endsWith(".rules")) continue; try { copyFileSync(join(userRulesDir, f), join(rulesDir, `user-${f}`)); - } catch { - /* skip an unreadable global rule rather than fail the run */ + } catch (err) { + // Skip an unreadable global rule rather than fail the run — but say + // so: the user's safety rules are being dropped for this agent's run. + console.warn( + `Agent Fleet: couldn't copy the Codex rules file ${join(userRulesDir, f)} into agent ` + + `"${agent.name}"'s permission overlay (${err instanceof Error ? err.message : String(err)}); ` + + `its rules will NOT apply to this agent's runs.`, + ); } } } diff --git a/src/adapters/parseHelpers.test.ts b/src/adapters/parseHelpers.test.ts new file mode 100644 index 0000000..d2eb2d2 --- /dev/null +++ b/src/adapters/parseHelpers.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseJsonLoud, tryParseJson, warnJsonParseFailure } from "./parseHelpers"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("tryParseJson", () => { + it("parses valid JSON of any shape", () => { + expect(tryParseJson('{"a":1}')).toEqual({ a: 1 }); + expect(tryParseJson("[1,2]")).toEqual([1, 2]); + expect(tryParseJson('"str"')).toBe("str"); + expect(tryParseJson("3")).toBe(3); + }); + + it("returns undefined for non-JSON without logging", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(tryParseJson("Loading model… please wait")).toBeUndefined(); + expect(tryParseJson("")).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("parseJsonLoud", () => { + it("parses valid JSON silently", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(parseJsonLoud("ctx", '{"ok":true}')).toEqual({ ok: true }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns with context, parse error, and a text preview on failure", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(parseJsonLoud("final result JSON", "{broken")).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("final result JSON"); + expect(message).toContain("{broken"); + }); +}); + +describe("warnJsonParseFailure", () => { + it("truncates the preview to ~200 chars", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const long = "x".repeat(500); + warnJsonParseFailure("ctx", long); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("x".repeat(200)); + expect(message).not.toContain("x".repeat(201)); + expect(message).toContain("…"); + }); + + it("includes the error message when an error is given", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnJsonParseFailure("ctx", "text", new Error("Unexpected token")); + expect(String(warn.mock.calls[0]?.[0])).toContain("Unexpected token"); + }); +}); diff --git a/src/adapters/parseHelpers.ts b/src/adapters/parseHelpers.ts new file mode 100644 index 0000000..785ffff --- /dev/null +++ b/src/adapters/parseHelpers.ts @@ -0,0 +1,49 @@ +/** + * Shared JSON-parsing helpers for the CLI adapters. + * + * Two distinct situations, two helpers: + * + * - `tryParseJson` — per-line parsing of stream-json/JSONL output, where + * non-JSON lines are EXPECTED (CLI banners, verbose noise, progress text). + * Fails silently by design; warning per line would spam the console. + * + * - `parseJsonLoud` / `warnJsonParseFailure` — for output that SHOULD be + * valid JSON (a whole-stdout result payload, or a stream that yielded no + * parseable event at all). Silence here hides real failures: the user sees + * "(no output)" with no clue that the CLI's output format drifted. These + * log a console.warn with the parse error and a preview of the offending + * text so version drift is debuggable. + */ + +/** Parse a line that may or may not be JSON. Returns undefined on failure + * WITHOUT logging — non-JSON lines are expected between stream events. */ +export function tryParseJson(text: string): unknown | undefined { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +const PREVIEW_LENGTH = 200; + +/** Warn that output which should have contained JSON didn't parse, with a + * ~200-char preview of the offending text and the parse error (if any). */ +export function warnJsonParseFailure(context: string, text: string, error?: unknown): void { + const preview = text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH)}…` : text; + const reason = + error instanceof Error ? error.message : error !== undefined ? String(error) : "no parseable JSON found"; + console.warn(`Agent Fleet: ${context} — ${reason}. Output begins: ${preview}`); +} + +/** Parse JSON that is expected to be valid (e.g. a whole-stdout result + * payload). Logs a console.warn via `warnJsonParseFailure` on failure and + * returns undefined so callers keep their existing fallback behavior. */ +export function parseJsonLoud(context: string, text: string): unknown | undefined { + try { + return JSON.parse(text) as unknown; + } catch (err) { + warnJsonParseFailure(context, text, err); + return undefined; + } +} diff --git a/src/defaults.ts b/src/defaults.ts index 1ebefbf..7840817 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -857,6 +857,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: \`@agent-name\` - Agents with \`approval_required\` set cannot be bound to a channel - Multi-agent routing: type \`@agent-name: message\` to switch agents, or use \`/agents\` for interactive picker - Obsidian must be running for channels to work — when closed, bots go offline +- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn — matching the in-app chat. Each injected follow-up gets its own reply; \`[REMEMBER]\` blocks are stripped from channel replies. ## Creating a Task diff --git a/src/fleetRepository.test.ts b/src/fleetRepository.test.ts new file mode 100644 index 0000000..428be11 --- /dev/null +++ b/src/fleetRepository.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The shared obsidian stub lacks App/FileSystemAdapter (fleetRepository +// references FileSystemAdapter at runtime via instanceof); extend it here. +// Its parseYaml also lacks inline-list support (`skills: [s1]`), which the +// reference-validation tests need — bolt on a minimal `[a, b]` parser. +vi.mock("obsidian", async (importOriginal) => { + const actual = await importOriginal>(); + const baseParseYaml = actual.parseYaml as (input: string) => Record; + return { + ...actual, + App: class App {}, + FileSystemAdapter: class FileSystemAdapter {}, + parseYaml: (input: string): Record => { + const out = baseParseYaml(input); + for (const [key, value] of Object.entries(out)) { + if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + out[key] = inner + ? inner.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")) + : []; + } + } + return out; + }, + }; +}); + +import type { App } from "obsidian"; +import { TFile, TFolder } from "obsidian"; +import { DEFAULT_SETTINGS } from "./constants"; +import { FleetRepository } from "./fleetRepository"; + +/** Minimal in-memory vault: enough surface for the repository paths under test. */ +class FakeVault { + files = new Map(); + contents = new Map(); + folders = new Map(); + cachedReadCalls = 0; + private clock = 1; + + addFile(path: string, content: string): TFile { + const existing = this.files.get(path); + if (existing) { + this.contents.set(path, content); + existing.stat.mtime = this.clock++; + existing.stat.size = content.length; + return existing; + } + const file = new TFile(); + file.path = path; + const base = path.split("/").pop() ?? ""; + file.basename = base.replace(/\.[^.]+$/, ""); + file.extension = base.split(".").pop() ?? ""; + file.stat = { ctime: this.clock, mtime: this.clock++, size: content.length }; + this.files.set(path, file); + this.contents.set(path, content); + this.ensureFolderChain(path.slice(0, path.lastIndexOf("/"))); + return file; + } + + private ensureFolderChain(path: string): void { + if (!path || this.folders.has(path)) return; + const folder = new TFolder(); + folder.path = path; + this.folders.set(path, folder); + const parent = path.slice(0, path.lastIndexOf("/")); + if (parent) this.ensureFolderChain(parent); + } + + getAbstractFileByPath(path: string): TFile | TFolder | null { + const file = this.files.get(path); + if (file) return file; + const folder = this.folders.get(path); + if (!folder) return null; + const children: Array = []; + for (const candidate of [...this.folders.values(), ...this.files.values()]) { + if (!candidate.path.startsWith(`${path}/`)) continue; + if (candidate.path.slice(path.length + 1).includes("/")) continue; + children.push(candidate); + } + folder.children = children; + return folder; + } + + async cachedRead(file: TFile): Promise { + this.cachedReadCalls++; + const content = this.contents.get(file.path); + if (content === undefined) throw new Error(`no such file: ${file.path}`); + return content; + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error("File already exists"); + return this.addFile(path, content); + } + + async createFolder(path: string): Promise { + if (this.folders.has(path)) throw new Error("Folder already exists"); + this.ensureFolderChain(path); + } + + async modify(file: TFile, content: string): Promise { + this.addFile(file.path, content); + } + + getMarkdownFiles(): TFile[] { + return [...this.files.values()].filter((f) => f.extension === "md"); + } + + removeTree(path: string): void { + this.files.delete(path); + this.contents.delete(path); + this.folders.delete(path); + for (const p of [...this.files.keys()]) { + if (p.startsWith(`${path}/`)) { + this.files.delete(p); + this.contents.delete(p); + } + } + for (const p of [...this.folders.keys()]) { + if (p.startsWith(`${path}/`)) this.folders.delete(p); + } + } +} + +function makeApp(vault: FakeVault): App { + return { + vault, + fileManager: { + trashFile: async (file: TFile | TFolder) => { + vault.removeTree(file.path); + }, + }, + } as unknown as App; +} + +describe("FleetRepository", () => { + let vault: FakeVault; + let repo: FleetRepository; + + beforeEach(() => { + vault = new FakeVault(); + repo = new FleetRepository(makeApp(vault), { ...DEFAULT_SETTINGS }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("name-keyed lookups", () => { + it("reflects loads, removals, and reloads (index never goes stale)", async () => { + vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\n---\n\nBody\n"); + vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: foo\ntype: immediate\n---\n\nDo it\n"); + vault.addFile("_fleet/skills/s1.md", "---\nname: s1\n---\n\nSkill\n"); + + await repo.loadFile("_fleet/agents/foo.md"); + await repo.loadFile("_fleet/tasks/t1.md"); + await repo.loadFile("_fleet/skills/s1.md"); + + expect(repo.getAgentByName("foo")?.filePath).toBe("_fleet/agents/foo.md"); + expect(repo.getTaskById("t1")?.filePath).toBe("_fleet/tasks/t1.md"); + expect(repo.getSkillByName("s1")?.filePath).toBe("_fleet/skills/s1.md"); + + repo.removeFile("_fleet/agents/foo.md"); + expect(repo.getAgentByName("foo")).toBeUndefined(); + + await repo.loadFile("_fleet/agents/foo.md"); + expect(repo.getAgentByName("foo")).toBeDefined(); + }); + + it("keeps the first entity when names collide (linear-scan parity)", async () => { + vault.addFile("_fleet/agents/a1.md", "---\nname: dup\nmodel: sonnet\n---\n\nA\n"); + vault.addFile("_fleet/agents/a2.md", "---\nname: dup\nmodel: sonnet\n---\n\nB\n"); + await repo.loadFile("_fleet/agents/a1.md"); + await repo.loadFile("_fleet/agents/a2.md"); + expect(repo.getAgentByName("dup")?.filePath).toBe("_fleet/agents/a1.md"); + }); + }); + + describe("corrupt permissions.json", () => { + it("loads the folder agent, logs an error, and records a validation issue", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vault.addFile("_fleet/agents/bot/agent.md", "---\nname: bot\n---\n\nPrompt\n"); + vault.addFile("_fleet/agents/bot/permissions.json", "{not json"); + + await repo.loadAll(); + + const agent = repo.getAgentByName("bot"); + expect(agent).toBeDefined(); + expect(agent?.permissionRules).toEqual({ allow: [], deny: [] }); + expect(errorSpy).toHaveBeenCalled(); + + const issues = repo + .getSnapshot() + .validationIssues.filter((i) => i.path === "_fleet/agents/bot/permissions.json"); + // Exactly one, even though loadAll also reloads the folder agent via + // loadFile(agent.md) — the stale issue is cleared before re-parsing. + expect(issues).toHaveLength(1); + }); + }); + + describe("migrateLegacyMemory", () => { + const legacyPath = "_fleet/memory/bot.md"; + const workingPath = "_fleet/memory/bot/working.md"; + + it("lets concurrent callers await the same in-flight migration", async () => { + vault.addFile(legacyPath, "---\nagent: bot\n---\n\n## Learned Context\n\n- fact one\n- fact two\n"); + + let secondSawWorking = false; + const first = repo.migrateLegacyMemory("bot"); + const second = repo.migrateLegacyMemory("bot").then(() => { + secondSawWorking = vault.getAbstractFileByPath(workingPath) !== null; + }); + await Promise.all([first, second]); + + // The second caller must resolve only after the migration completed. + expect(secondSawWorking).toBe(true); + expect(vault.getAbstractFileByPath(workingPath)).toBeInstanceOf(TFile); + expect(vault.getAbstractFileByPath(legacyPath)).toBeNull(); + + const day = new Date().toISOString().slice(0, 10); + const raw = vault.contents.get(`_fleet/memory/bot/raw/${day}.md`) ?? ""; + expect(raw.match(/\[migrated\] Imported/g)).toHaveLength(1); + + // A later call is a no-op — no double seeding. + await repo.migrateLegacyMemory("bot"); + const rawAfter = vault.contents.get(`_fleet/memory/bot/raw/${day}.md`) ?? ""; + expect(rawAfter).toBe(raw); + }); + }); + + describe("readRunLog cache", () => { + it("returns the cached parse until mtime changes", async () => { + const file = vault.addFile( + "_fleet/runs/2026-07-01/000000-a-t.md", + "---\nrun_id: r1\nagent: a\ntask: t\nstatus: success\nstarted: 2026-07-01T00:00:00Z\n---\n\n## Prompt\n\nhi\n\n## Output\n\nok\n", + ); + + const first = await repo.readRunLog(file); + expect(first?.status).toBe("success"); + const readsAfterFirst = vault.cachedReadCalls; + + const second = await repo.readRunLog(file); + expect(second).toBe(first); // cached object, no re-read + expect(vault.cachedReadCalls).toBe(readsAfterFirst); + + // Modifying the file bumps mtime → cache invalidates. + vault.addFile( + file.path, + "---\nrun_id: r1\nagent: a\ntask: t\nstatus: failure\nstarted: 2026-07-01T00:00:00Z\n---\n\n## Prompt\n\nhi\n\n## Output\n\nboom\n", + ); + const third = await repo.readRunLog(file); + expect(third?.status).toBe("failure"); + expect(vault.cachedReadCalls).toBe(readsAfterFirst + 1); + }); + }); + + describe("reference validation re-runs after mutations", () => { + it("flags an agent's dangling skill reference immediately after deleteSkill", async () => { + vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\nskills: [s1]\n---\n\nBody\n"); + vault.addFile("_fleet/skills/s1.md", "---\nname: s1\n---\n\nSkill\n"); + await repo.loadAll(); + expect(repo.getSnapshot().validationIssues).toHaveLength(0); + + await repo.deleteSkill("s1"); + + // No loadAll() in between — the issue must appear from the delete alone. + const issues = repo.getSnapshot().validationIssues; + expect( + issues.some( + (i) => i.path === "_fleet/agents/foo.md" && i.message.includes("missing skill `s1`"), + ), + ).toBe(true); + expect(repo.getSkillByName("s1")).toBeUndefined(); + }); + + it("flags a task's dangling agent reference immediately after deleteAgent", async () => { + vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\n---\n\nBody\n"); + vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: foo\ntype: immediate\n---\n\nDo\n"); + await repo.loadAll(); + expect(repo.getSnapshot().validationIssues).toHaveLength(0); + + await repo.deleteAgent("foo", false); + + expect( + repo.getSnapshot().validationIssues.some( + (i) => i.path === "_fleet/tasks/t1.md" && i.message.includes("missing agent `foo`"), + ), + ).toBe(true); + }); + + it("clears a dangling-skill issue as soon as the skill is created", async () => { + vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\nskills: [s1]\n---\n\nBody\n"); + await repo.loadAll(); + expect( + repo.getSnapshot().validationIssues.some((i) => i.message.includes("missing skill `s1`")), + ).toBe(true); + + await repo.createSkillTemplate("s1"); + + expect( + repo.getSnapshot().validationIssues.some((i) => i.message.includes("missing skill `s1`")), + ).toBe(false); + }); + + it("re-running validation neither duplicates reference issues nor clears load-time corrupt-file issues", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + // Load-time issue: corrupt permissions.json on a folder agent. + vault.addFile("_fleet/agents/bot/agent.md", "---\nname: bot\n---\n\nPrompt\n"); + vault.addFile("_fleet/agents/bot/permissions.json", "{not json"); + // Reference issue: task pointing at a missing agent. + vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: ghost\ntype: immediate\n---\n\nDo\n"); + await repo.loadAll(); + + const count = (issues: Array<{ path: string; message: string }>) => ({ + corrupt: issues.filter((i) => i.path === "_fleet/agents/bot/permissions.json").length, + dangling: issues.filter((i) => i.message.includes("missing agent `ghost`")).length, + }); + expect(count(repo.getSnapshot().validationIssues)).toEqual({ corrupt: 1, dangling: 1 }); + + // Two mutations, each re-running validation. + await repo.updateTask("t1", { priority: "high" }); + await repo.updateHeartbeat("bot", { enabled: false }); + + expect(count(repo.getSnapshot().validationIssues)).toEqual({ corrupt: 1, dangling: 1 }); + }); + }); + + describe("fail-soft reads warn instead of staying silent", () => { + it("readCandidates returns [] and warns on corrupt JSON", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vault.addFile("_fleet/memory/bot/candidates.json", "{oops"); + expect(await repo.readCandidates("bot")).toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/fleetRepository.ts b/src/fleetRepository.ts index 49f80ce..627911f 100644 --- a/src/fleetRepository.ts +++ b/src/fleetRepository.ts @@ -1,22 +1,29 @@ -import { join } from "path"; -import { App, FileSystemAdapter, TFile, TFolder, Vault, normalizePath } from "obsidian"; -import { - DEFAULT_SETTINGS, - DEFAULT_MEMORY_TOKEN_BUDGET, - DEFAULT_REFLECTION_SCHEDULE, - DEFAULT_RECURRENCE_THRESHOLD, - FLEET_SUBFOLDERS, -} from "./constants"; +import { App, FileSystemAdapter, TFile, Vault, normalizePath } from "obsidian"; +import { FLEET_SUBFOLDERS } from "./constants"; import { DEFAULT_FILES } from "./defaults"; -import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "./utils/markdown"; import { - migrateLegacyBody, - parseWorkingMemory, - renderSections, - serializeWorkingMemory, -} from "./utils/memoryFormat"; -import { splitLines } from "./utils/platform"; -import { deltaizeCumulativeCosts } from "./utils/usageMigration"; + createFileIfMissing, + ensureFolder, + getAvailablePath, + trashPath, +} from "./repository/shared"; +import { ConversationStore } from "./repository/conversationStore"; +import { EntityMutations } from "./repository/entityMutations"; +import type { + AgentUpdates, + ChannelUpdates, + CreateAgentFolderOptions, + CreateSkillFolderOptions, + HeartbeatUpdates, + SkillUpdates, + TaskUpdates, +} from "./repository/entityMutations"; +import { EntityStore } from "./repository/entityStore"; +import { MemoryStore } from "./repository/memoryStore"; +import { McpRegistry } from "./repository/mcpRegistry"; +import { ProposalStore } from "./repository/proposalStore"; +import { RunLogStore } from "./repository/runLogStore"; +import { UsageLedger } from "./repository/usageLedger"; import type { AgentConfig, ChannelConfig, @@ -26,7 +33,6 @@ import type { FleetSnapshot, MemoryFile, McpServer, - ReflectionConfig, RunLogData, SkillCandidate, SkillProposal, @@ -34,92 +40,8 @@ import type { SkillConfig, TaskConfig, UsageRecord, - ValidationIssue, } from "./types"; -type ParsedEntity = AgentConfig | SkillConfig | TaskConfig; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function asNumber(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function asStringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; -} - -/** Coerce a frontmatter value into a `Record`, dropping any - * non-string values. Returns undefined when there's nothing usable so callers - * can omit the field entirely. */ -function asStringMap(value: unknown): Record | undefined { - if (!isRecord(value)) return undefined; - const out: Record = {}; - for (const [k, v] of Object.entries(value)) { - if (typeof v === "string") out[k] = v; - } - return Object.keys(out).length > 0 ? out : undefined; -} - -let warnedLegacyBudget = false; - -/** - * Resolve an agent's working-memory token budget. Prefers the new - * `memory_token_budget`; if absent but the deprecated `memory_max_entries` is - * present, fall back to the default budget and emit a one-time console notice - * (design §13.4 — the old entry-count knob no longer maps cleanly to a budget). - */ -function resolveMemoryTokenBudget( - fm: Record, - fallback: Record = {}, -): number { - const explicit = fm.memory_token_budget ?? fallback.memory_token_budget; - if (explicit !== undefined) return asNumber(explicit, DEFAULT_MEMORY_TOKEN_BUDGET); - const legacy = fm.memory_max_entries ?? fallback.memory_max_entries; - if (legacy !== undefined && !warnedLegacyBudget) { - warnedLegacyBudget = true; - console.info( - "Agent Fleet: `memory_max_entries` is deprecated and no longer enforced; " + - "memory is now bounded by `memory_token_budget` (default " + - `${DEFAULT_MEMORY_TOKEN_BUDGET}). Set that field to tune memory size.`, - ); - } - return DEFAULT_MEMORY_TOKEN_BUDGET; -} - -/** Build a {@link ReflectionConfig} from frontmatter, with an optional fallback - * frontmatter (folder agents split fields across config.md and agent.md). */ -function parseReflectionConfig( - fm: Record, - fallback: Record = {}, -): ReflectionConfig { - const pick = (key: string): unknown => fm[key] ?? fallback[key]; - return { - enabled: asBoolean(pick("reflection_enabled"), false), - schedule: asString(pick("reflection_schedule")) ?? DEFAULT_REFLECTION_SCHEDULE, - recurrenceThreshold: asNumber(pick("reflection_recurrence_threshold"), DEFAULT_RECURRENCE_THRESHOLD), - proposeSkills: asBoolean(pick("reflection_propose_skills"), false), - model: asString(pick("reflection_model")), - }; -} - -/** For a flat agent at `/.md`, the permissions sidecar lives at - * `/.permissions.json`. Folder agents use `/permissions.json` - * (handled in loadFolderAgent). */ -function sidecarPermissionsPath(agentMdPath: string): string { - return normalizePath(agentMdPath.replace(/\.md$/, ".permissions.json")); -} - /** Fast non-cryptographic hash for change detection on default files. */ function simpleHash(str: string): string { let hash = 0; @@ -130,32 +52,75 @@ function simpleHash(str: string): string { return hash.toString(36); } +/** + * Facade over the `_fleet/` file tree. All state and behavior lives in the + * extracted stores under src/repository/ — this class owns the wiring + * (constructing each store with lazy path getters and cross-store callbacks), + * fleet-folder structure + default-file management, and the few operations + * that genuinely span stores (e.g. {@link migrateAllLegacyMemory}, which needs + * the entity snapshot to drive the memory store). + * + * The entity cluster (parsing, load flow, validation issues, entity CRUD) is + * split across EntityStore (in-memory maps + load/validate), + * EntityMutations (persisting create/update/delete), and entityParsers + * (flat + folder frontmatter parsing). + */ export class FleetRepository { - private agents = new Map(); - private skills = new Map(); - private tasks = new Map(); - private channels = new Map(); - private mcpServers = new Map(); - private validationIssues = new Map(); - - private channelCredentialGetter?: () => Record; - - /** Agents we've already logged a folder-model-conflict warning for. */ - private warnedFolderAgentModelConflict = new Set(); - private warnedLegacyPerms = new Set(); - /** Agents whose legacy→v2 memory migration is in flight, so the unlocked - * reflection path and the lock-held capture path can't double-seed raw. */ - private migratingMemory = new Set(); - private readonly vault: Vault; + + // Extracted stores — the facade delegates to these; construction order is + // irrelevant because they receive lazy path getters, not computed paths + // (settings.fleetFolder can change between calls). Cross-store callbacks + // (mcp → entity issue map, proposal/mcp live lookups) are routed through + // facade arrow functions so each store sees the other's CURRENT state. + private readonly entities: EntityStore; + private readonly mutations: EntityMutations; + private readonly runLogs: RunLogStore; + private readonly usage: UsageLedger; + private readonly conversations: ConversationStore; + private readonly proposals: ProposalStore; + private readonly mcp: McpRegistry; + private readonly memory: MemoryStore; + constructor(private readonly app: App, private readonly settings: FleetSettings) { this.vault = app.vault; + this.entities = new EntityStore(app, { + settings, + getFleetRoot: () => this.getFleetRoot(), + getSubfolder: (name) => this.getSubfolder(name), + // Lazy: this.mcp is assigned below, and the callback only fires during + // load — never during construction. + parseMcpServerFile: (path, content) => this.mcp.parseMcpServerFile(path, content), + }); + this.mutations = new EntityMutations(app, { + store: this.entities, + getSubfolder: (name) => this.getSubfolder(name), + // Deleting an agent also trashes its MemoryStore-owned files. + getMemoryPath: (agentName) => this.getMemoryPath(agentName), + getMemoryDir: (agentName) => this.getMemoryDir(agentName), + }); + this.runLogs = new RunLogStore(app, () => this.getRunsRoot()); + this.usage = new UsageLedger(app, () => this.getSubfolder("usage")); + this.memory = new MemoryStore(app, () => this.getSubfolder("memory")); + this.conversations = new ConversationStore(app, (agentName) => this.getMemoryPath(agentName)); + this.proposals = new ProposalStore(app, { + getFleetRoot: () => this.getFleetRoot(), + getSkillsDir: () => this.getSubfolder("skills"), + getSkillByName: (name) => this.getSkillByName(name), + }); + this.mcp = new McpRegistry(app, { + getMcpDir: () => this.getSubfolder("mcp"), + getServerByName: (name) => this.getMcpServerByName(name), + // Registry parse errors land in the entity store's load-time + // validation-issue map, same as every other parser's. + reportIssue: (path, message) => this.entities.setIssue(path, message), + }); } /** Inject a live credential getter so validation reads from the credential store * instead of the (possibly empty post-migration) settings.channelCredentials. */ setChannelCredentialGetter(getter: () => Record): void { - this.channelCredentialGetter = getter; + this.entities.setChannelCredentialGetter(getter); } getVaultBasePath(): string | undefined { @@ -174,9 +139,9 @@ export class FleetRepository { async ensureFleetStructure(): Promise { const root = this.getFleetRoot(); const isFirstRun = !this.vault.getAbstractFileByPath(root); - await this.ensureFolder(root); + await ensureFolder(this.vault, root); for (const subfolder of FLEET_SUBFOLDERS) { - await this.ensureFolder(this.getSubfolder(subfolder)); + await ensureFolder(this.vault, this.getSubfolder(subfolder)); } return isFirstRun; } @@ -186,8 +151,8 @@ export class FleetRepository { for (const file of DEFAULT_FILES) { const fullPath = normalizePath(`${root}/${file.path}`); const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/")); - await this.ensureFolder(parentDir); - await this.createFileIfMissing(fullPath, file.content); + await ensureFolder(this.vault, parentDir); + await createFileIfMissing(this.vault, fullPath, file.content); } } @@ -215,8 +180,8 @@ export class FleetRepository { if (!(existing instanceof TFile)) { // File doesn't exist → write it const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/")); - await this.ensureFolder(parentDir); - await this.createFileIfMissing(fullPath, file.content); + await ensureFolder(this.vault, parentDir); + await createFileIfMissing(this.vault, fullPath, file.content); updatedHashes[file.path] = newHash; continue; } @@ -236,974 +201,227 @@ export class FleetRepository { return updatedHashes; } + // ═══════════════════════════════════════════════════════ + // Entity load flow + lookups — delegates to EntityStore + // ═══════════════════════════════════════════════════════ + + /** Reload every entity under the fleet root from scratch. See + * {@link EntityStore.loadAll} for the bulk-load validation suppression. */ async loadAll(): Promise { - this.agents.clear(); - this.skills.clear(); - this.tasks.clear(); - this.channels.clear(); - this.mcpServers.clear(); - this.validationIssues.clear(); - - // Load folder-based agents and skills first - await this.loadFolderAgents(); - await this.loadFolderSkills(); - - const files = this.vault - .getMarkdownFiles() - .filter((file) => file.path.startsWith(`${this.getFleetRoot()}/`)); - - for (const file of files) { - await this.loadFile(file); - } - - this.validateReferences(); - return this.getSnapshot(); + return this.entities.loadAll(); } + /** (Re)load one file into the in-memory maps and revalidate references. */ async loadFile(fileOrPath: TFile | string): Promise { - const file = typeof fileOrPath === "string" ? this.vault.getAbstractFileByPath(fileOrPath) : fileOrPath; - if (!(file instanceof TFile) || file.extension !== "md") { - return; - } - - // Files inside agent folders need to reload the whole folder agent - if (this.isInsideAgentFolder(file.path)) { - await this.reloadFolderAgentContaining(file.path); - return; - } - - // Files inside skill folders need to reload the whole folder skill - if (this.isInsideSkillFolder(file.path)) { - await this.reloadFolderSkillContaining(file.path); - return; - } - - this.clearStoredFile(file.path); - - // Channel files live under _fleet/channels/ and are parsed through a dedicated - // path that does NOT flow through parseFile(). This keeps channels out of the - // ParsedEntity union (AgentConfig | SkillConfig | TaskConfig) and lets their - // validation (type discrimination, approval_required block) live beside the - // parser. - const channelsPrefix = `${this.getSubfolder("channels")}/`; - if (file.path.startsWith(channelsPrefix)) { - // Skip nested files (e.g. sessions/.json). Only top-level *.md are channel configs. - const rel = file.path.slice(channelsPrefix.length); - if (!rel.includes("/")) { - const content = await this.vault.cachedRead(file); - const channel = this.parseChannelFile(file.path, content); - if (channel) { - this.channels.set(file.path, channel); - } - } - return; - } - - // MCP server registry files live under _fleet/mcp/ and, like channels, are - // parsed through a dedicated path that does NOT flow through parseFile() - // (they're not part of the ParsedEntity union). - const mcpPrefix = `${this.getSubfolder("mcp")}/`; - if (file.path.startsWith(mcpPrefix)) { - const rel = file.path.slice(mcpPrefix.length); - if (!rel.includes("/")) { - const content = await this.vault.cachedRead(file); - const server = this.parseMcpServerFile(file.path, content); - if (server) { - this.mcpServers.set(file.path, server); - } - } - return; - } - - const content = await this.vault.cachedRead(file); - const parsed = this.parseFile(file.path, content); - if (parsed) { - if ("taskId" in parsed) { - this.tasks.set(file.path, parsed); - } else if ("model" in parsed) { - // Flat agent: sidecar `.permissions.json` wins over legacy - // frontmatter allowed_tools/blocked_tools when both exist. - if (!parsed.isFolder) { - const sidecarPath = sidecarPermissionsPath(file.path); - const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath); - if (sidecarFile instanceof TFile) { - try { - const sidecarContent = await this.vault.cachedRead(sidecarFile); - const data = JSON.parse(sidecarContent) as Record; - parsed.permissionRules = { - allow: asStringArray(data.allow), - deny: asStringArray(data.deny), - }; - } catch { - // Invalid sidecar JSON — keep whatever permissionRules parseAgent assigned. - } - } - } - this.agents.set(file.path, parsed); - } else { - this.skills.set(file.path, parsed); - } - } - } - - private async reloadFolderAgentContaining(filePath: string): Promise { - const agentsDir = `${this.getSubfolder("agents")}/`; - const relative = filePath.slice(agentsDir.length); - const folderName = relative.split("/")[0]; - if (!folderName) return; - - const folderPath = normalizePath(`${agentsDir}${folderName}`); - const agentMdPath = normalizePath(`${folderPath}/agent.md`); - - // Clear any existing entry for this folder agent - this.agents.delete(agentMdPath); - - const folder = this.vault.getAbstractFileByPath(folderPath); - if (!(folder instanceof TFolder)) return; - - const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath); - if (!(agentMdFile instanceof TFile)) return; - - const agent = await this.loadFolderAgent(folderPath, agentMdFile); - if (agent) { - this.agents.set(agentMdPath, agent); - } - } - - private isInsideAgentFolder(path: string): boolean { - const agentsDir = `${this.getSubfolder("agents")}/`; - if (!path.startsWith(agentsDir)) return false; - const relative = path.slice(agentsDir.length); - return relative.includes("/"); - } - - private isInsideSkillFolder(path: string): boolean { - const skillsDir = `${this.getSubfolder("skills")}/`; - if (!path.startsWith(skillsDir)) return false; - const relative = path.slice(skillsDir.length); - return relative.includes("/"); - } - - private async reloadFolderSkillContaining(filePath: string): Promise { - const skillsDir = `${this.getSubfolder("skills")}/`; - const relative = filePath.slice(skillsDir.length); - const folderName = relative.split("/")[0]; - if (!folderName) return; - - const folderPath = normalizePath(`${skillsDir}${folderName}`); - const skillMdPath = normalizePath(`${folderPath}/skill.md`); - - this.skills.delete(skillMdPath); - - const folder = this.vault.getAbstractFileByPath(folderPath); - if (!(folder instanceof TFolder)) return; - - const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath); - if (!(skillMdFile instanceof TFile)) return; - - const skill = await this.loadFolderSkill(folderPath, skillMdFile); - if (skill) { - this.skills.set(skillMdPath, skill); - } - } - - private async loadFolderSkills(): Promise { - const skillsFolder = this.vault.getAbstractFileByPath(this.getSubfolder("skills")); - if (!(skillsFolder instanceof TFolder)) return; - - for (const child of skillsFolder.children) { - if (!(child instanceof TFolder)) continue; - const skillMdPath = normalizePath(`${child.path}/skill.md`); - const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath); - if (!(skillMdFile instanceof TFile)) continue; - - const skill = await this.loadFolderSkill(child.path, skillMdFile); - if (skill) { - this.skills.set(skillMdPath, skill); - } - } - } - - private async loadFolderSkill(folderPath: string, skillMdFile: TFile): Promise { - const skillContent = await this.vault.cachedRead(skillMdFile); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(skillContent); - - const name = asString(frontmatter.name); - if (!name) { - this.setIssue(skillMdFile.path, "Folder skill skill.md requires string field `name`."); - return null; - } - - const readBody = async (fileName: string): Promise => { - const path = normalizePath(`${folderPath}/${fileName}`); - const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return ""; - const content = await this.vault.cachedRead(file); - const parsed = parseMarkdownWithFrontmatter>(content); - return parsed.body; - }; - - return { - filePath: skillMdFile.path, - name, - description: asString(frontmatter.description), - tags: asStringArray(frontmatter.tags), - body, - toolsBody: await readBody("tools.md"), - referencesBody: await readBody("references.md"), - examplesBody: await readBody("examples.md"), - isFolder: true, - }; - } - - private async loadFolderAgents(): Promise { - const agentsFolder = this.vault.getAbstractFileByPath(this.getSubfolder("agents")); - if (!(agentsFolder instanceof TFolder)) return; - - for (const child of agentsFolder.children) { - if (!(child instanceof TFolder)) continue; - const agentMdPath = normalizePath(`${child.path}/agent.md`); - const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath); - if (!(agentMdFile instanceof TFile)) continue; - - const agent = await this.loadFolderAgent(child.path, agentMdFile); - if (agent) { - this.agents.set(agentMdPath, agent); - } - } - } - - private async loadFolderAgent(folderPath: string, agentMdFile: TFile): Promise { - const agentContent = await this.vault.cachedRead(agentMdFile); - const { frontmatter: agentFm, body: agentBody } = parseMarkdownWithFrontmatter>(agentContent); - - const name = asString(agentFm.name); - if (!name) { - this.setIssue(agentMdFile.path, "Folder agent agent.md requires string field `name`."); - return null; - } - - // Read config.md - let configFm: Record = {}; - const configPath = normalizePath(`${folderPath}/config.md`); - const configFile = this.vault.getAbstractFileByPath(configPath); - if (configFile instanceof TFile) { - const configContent = await this.vault.cachedRead(configFile); - const parsed = parseMarkdownWithFrontmatter>(configContent); - configFm = parsed.frontmatter; - } - - // Read permissions.json — canonical source of truth for allow/deny rules. - // Legacy migration: if permissions.json is missing or empty BUT config.md - // carries `allowed_tools`/`blocked_tools` (the dead surface from older - // versions of this plugin), surface those values as permissionRules so - // the agent keeps working. The next call to updateAgent / updateWiki- - // KeeperAgent rewrites them into permissions.json proper. - let permissionRules: { allow: string[]; deny: string[] } = { allow: [], deny: [] }; - const permissionsPath = normalizePath(`${folderPath}/permissions.json`); - const permissionsFile = this.vault.getAbstractFileByPath(permissionsPath); - if (permissionsFile instanceof TFile) { - try { - const permContent = await this.vault.cachedRead(permissionsFile); - const parsed = JSON.parse(permContent) as Record; - permissionRules = { - allow: asStringArray(parsed.allow), - deny: asStringArray(parsed.deny), - }; - } catch { - // Invalid JSON — ignore - } - } - if (permissionRules.allow.length === 0 && permissionRules.deny.length === 0) { - const legacyAllow = asStringArray(configFm.allowed_tools); - const legacyDeny = asStringArray(configFm.blocked_tools); - if (legacyAllow.length > 0 || legacyDeny.length > 0) { - permissionRules = { allow: legacyAllow, deny: legacyDeny }; - if (!this.warnedLegacyPerms.has(name)) { - this.warnedLegacyPerms.add(name); - console.warn( - `Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in config.md. ` + - `Permission rules now live in permissions.json. Open this agent in Edit and Save to migrate.`, - ); - } - } - } - - // Read SKILLS.md body - let skillsBody = ""; - const skillsPath = normalizePath(`${folderPath}/SKILLS.md`); - const skillsFile = this.vault.getAbstractFileByPath(skillsPath); - if (skillsFile instanceof TFile) { - const skillsContent = await this.vault.cachedRead(skillsFile); - const parsed = parseMarkdownWithFrontmatter>(skillsContent); - skillsBody = parsed.body; - } - - // Read CONTEXT.md body - let contextBody = ""; - const contextPath = normalizePath(`${folderPath}/CONTEXT.md`); - const contextFile = this.vault.getAbstractFileByPath(contextPath); - if (contextFile instanceof TFile) { - const contextContent = await this.vault.cachedRead(contextFile); - const parsed = parseMarkdownWithFrontmatter>(contextContent); - contextBody = parsed.body; - } - - // Read HEARTBEAT.md — autonomous periodic run instruction - let heartbeatEnabled = false; - let heartbeatSchedule = ""; - let heartbeatBody = ""; - let heartbeatNotify = true; - let heartbeatChannel = ""; - let heartbeatChannelTarget = ""; - const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`); - const heartbeatFile = this.vault.getAbstractFileByPath(heartbeatPath); - if (heartbeatFile instanceof TFile) { - const heartbeatContent = await this.vault.cachedRead(heartbeatFile); - const parsed = parseMarkdownWithFrontmatter>(heartbeatContent); - heartbeatEnabled = asBoolean(parsed.frontmatter.enabled, false); - heartbeatSchedule = asString(parsed.frontmatter.schedule) ?? ""; - heartbeatNotify = asBoolean(parsed.frontmatter.notify, true); - heartbeatChannel = asString(parsed.frontmatter.channel) ?? ""; - heartbeatChannelTarget = asString(parsed.frontmatter.channel_target) ?? ""; - heartbeatBody = parsed.body; - } - - // Model canonical home is config.md for folder agents. agent.md's `model:` - // field is deprecated but still warned-about when it conflicts with - // config.md so users can clean up their vaults. - const agentMdModel = asString(agentFm.model); - const configModel = asString(configFm.model); - if (agentMdModel && configModel && agentMdModel !== configModel) { - if (!this.warnedFolderAgentModelConflict.has(name)) { - this.warnedFolderAgentModelConflict.add(name); - console.warn( - `Agent Fleet: "${name}" has conflicting model fields — agent.md says "${agentMdModel}", config.md says "${configModel}". config.md wins. Remove agent.md's model field or sync the values to silence this warning.`, - ); - } - } - const model = configModel ?? agentMdModel ?? this.settings.defaultModel; - - return { - filePath: agentMdFile.path, - name, - description: asString(agentFm.description), - model, - adapter: asString(configFm.adapter) ?? "claude-code", - permissionMode: asString(configFm.permission_mode) ?? "bypassPermissions", - effort: asString(configFm.effort), - maxRetries: asNumber(configFm.max_retries, 1), - skills: asStringArray(agentFm.skills), - mcpServers: asStringArray(agentFm.mcp_servers), - cwd: asString(configFm.cwd) || asString(agentFm.cwd), - enabled: asBoolean(agentFm.enabled, true), - timeout: asNumber(configFm.timeout, asNumber(agentFm.timeout, 300)), - approvalRequired: asStringArray(configFm.approval_required), - memory: asBoolean(configFm.memory, asBoolean(agentFm.memory, false)), - memoryMaxEntries: asNumber(configFm.memory_max_entries, 100), - memoryTokenBudget: resolveMemoryTokenBudget(configFm, agentFm), - reflection: parseReflectionConfig(configFm, agentFm), - autoCompactThreshold: asNumber( - configFm.auto_compact_threshold ?? agentFm.auto_compact_threshold, - 85, - ), - tags: asStringArray(agentFm.tags), - avatar: asString(agentFm.avatar) ?? "", - body: agentBody, - contextBody, - skillsBody, - env: this.parseEnvMap(configFm.env), - permissionRules, - isFolder: true, - heartbeatEnabled, - heartbeatSchedule, - heartbeatBody, - heartbeatNotify, - heartbeatChannel, - heartbeatChannelTarget, - wikiKeeper: this.parseWikiKeeperConfig(configFm.wiki_keeper ?? agentFm.wiki_keeper), - wikiReferences: this.parseWikiReferences(configFm.wiki_references ?? agentFm.wiki_references), - }; - } - - /** Parse a `wiki_references:` frontmatter list into an array of references. - * Accepts either `[{ agent: "name" }, ...]` or shorthand `["name", ...]`. - * Returns undefined when the field is absent. */ - private parseWikiReferences(raw: unknown): AgentConfig["wikiReferences"] { - if (!Array.isArray(raw)) return undefined; - const refs: Array<{ agent: string }> = []; - for (const item of raw) { - if (typeof item === "string" && item.trim()) { - refs.push({ agent: item.trim() }); - } else if (item && typeof item === "object") { - const agent = (item as Record).agent; - if (typeof agent === "string" && agent.trim()) { - refs.push({ agent: agent.trim() }); - } - } - } - return refs.length > 0 ? refs : undefined; - } - - /** Parse a `wiki_keeper:` frontmatter block into a WikiKeeperConfig. - * Returns undefined when no block is present — agents without this - * are regular agents, unaffected. */ - private parseWikiKeeperConfig(raw: unknown): AgentConfig["wikiKeeper"] { - if (!raw || typeof raw !== "object") return undefined; - const r = raw as Record; - return { - scopeRoot: asString(r.scope_root) ?? "", - inboxPath: asString(r.inbox_path) ?? "_sources/inbox", - archivePath: asString(r.archive_path) ?? "_sources/archive", - failedPath: asString(r.failed_path) ?? "_sources/failed", - topicsRoot: asString(r.topics_root) ?? "_topics", - indexPath: asString(r.index_path) ?? "index.md", - logPath: asString(r.log_path) ?? "log.md", - watchedFolders: asStringArray(r.watched_folders), - excludePatterns: asStringArray(r.exclude_patterns), - watchedSince: asString(r.watched_since) ?? "", - // Default flipped to TRUE in v1.3 — Karpathy compounding requires - // substantive answers to file themselves back into the wiki. - fileSubstantiveAnswers: asBoolean(r.file_substantive_answers, true), - obsidianUrlScheme: asBoolean(r.obsidian_url_scheme, true), - maxTokensPerIngest: asNumber(r.max_tokens_per_ingest, 60000), - maxTokensPerRefresh: asNumber(r.max_tokens_per_refresh, 30000), - indexSplitThreshold: asNumber(r.index_split_threshold, 100), - dedupSimilarityThreshold: asNumber(r.dedup_similarity_threshold, 0.82), - summaryStaleDays: asNumber(r.summary_stale_days, 30), - stateFile: asString(r.state_file) ?? ".wiki-keeper-state.json", - }; + return this.entities.loadFile(fileOrPath); } + /** Drop a removed file's entities/issues and revalidate references. */ removeFile(path: string): void { - this.clearStoredFile(path); + this.entities.removeFile(path); } getSnapshot(): FleetSnapshot { - return { - agents: Array.from(this.agents.values()).sort((a, b) => a.name.localeCompare(b.name)), - skills: Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name)), - tasks: Array.from(this.tasks.values()).sort((a, b) => a.taskId.localeCompare(b.taskId)), - channels: Array.from(this.channels.values()).sort((a, b) => a.name.localeCompare(b.name)), - mcpServers: Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)), - validationIssues: Array.from(this.validationIssues.values()).flat(), - }; + return this.entities.getSnapshot(); } getAgentByName(name: string): AgentConfig | undefined { - return Array.from(this.agents.values()).find((agent) => agent.name === name); + return this.entities.getAgentByName(name); } getSkillByName(name: string): SkillConfig | undefined { - return Array.from(this.skills.values()).find((skill) => skill.name === name); + return this.entities.getSkillByName(name); } getTaskById(taskId: string): TaskConfig | undefined { - return Array.from(this.tasks.values()).find((task) => task.taskId === taskId); + return this.entities.getTaskById(taskId); } getTasksForAgent(agentName: string): TaskConfig[] { - return Array.from(this.tasks.values()).filter((task) => task.agent === agentName); + return this.entities.getTasksForAgent(agentName); } getChannelByName(name: string): ChannelConfig | undefined { - return Array.from(this.channels.values()).find((channel) => channel.name === name); + return this.entities.getChannelByName(name); } getChannelsForAgent(agentName: string): ChannelConfig[] { - return Array.from(this.channels.values()).filter((channel) => channel.defaultAgent === agentName); + return this.entities.getChannelsForAgent(agentName); } /** All registered MCP servers, sorted by name. */ getMcpServers(): McpServer[] { - return Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)); + return this.entities.getMcpServers(); } getMcpServerByName(name: string): McpServer | undefined { - return Array.from(this.mcpServers.values()).find((s) => s.name === name); + return this.entities.getMcpServerByName(name); } getRunsRoot(): string { return this.getSubfolder("runs"); } + // ═══════════════════════════════════════════════════════ + // Agent memory (v2 layout + legacy migration) — delegates to MemoryStore + // ═══════════════════════════════════════════════════════ + /** @deprecated Legacy flat memory file path. New layout uses * {@link getWorkingMemoryPath}. Retained for migration + back-compat. */ getMemoryPath(agentName: string): string { - return normalizePath(`${this.getSubfolder("memory")}/${slugify(agentName)}.md`); + return this.memory.getMemoryPath(agentName); } /** Folder holding an agent's v2 memory: working.md, candidates.md, raw/. */ getMemoryDir(agentName: string): string { - return normalizePath(`${this.getSubfolder("memory")}/${slugify(agentName)}`); + return this.memory.getMemoryDir(agentName); } /** Path to the curated, injected working-memory file (§6.2). */ getWorkingMemoryPath(agentName: string): string { - return normalizePath(`${this.getMemoryDir(agentName)}/working.md`); + return this.memory.getWorkingMemoryPath(agentName); } /** Path to a day's append-only raw archive (§6.3). */ getRawMemoryPath(agentName: string, dateIso: string): string { - const day = dateIso.slice(0, 10); // YYYY-MM-DD - return normalizePath(`${this.getMemoryDir(agentName)}/raw/${day}.md`); + return this.memory.getRawMemoryPath(agentName, dateIso); } - /** - * Read an agent's working memory as the structured v2 object. Resolves either - * layout: the v2 `working.md` if present, otherwise the legacy flat file - * migrated in-memory (the first write persists the folder layout). Returns - * null when the agent has no memory yet. - */ + /** Read an agent's working memory (v2 or legacy migrated in-memory). */ async readWorkingMemory(agentName: string): Promise { - const workingPath = this.getWorkingMemoryPath(agentName); - const workingFile = this.vault.getAbstractFileByPath(workingPath); - if (workingFile instanceof TFile) { - const content = await this.vault.cachedRead(workingFile); - return parseWorkingMemory(content, workingPath, agentName); - } - - const legacyPath = this.getMemoryPath(agentName); - const legacyFile = this.vault.getAbstractFileByPath(legacyPath); - if (legacyFile instanceof TFile) { - const content = await this.vault.cachedRead(legacyFile); - const { body } = parseMarkdownWithFrontmatter>(content); - return migrateLegacyBody(body, workingPath, agentName); - } - - return null; + return this.memory.readWorkingMemory(agentName); } - /** - * Persist working memory to `working.md`, creating the folder layout and - * trashing the legacy flat file if one still exists (completes migration). - */ + /** Persist working memory to `working.md` (completes legacy migration). */ async writeWorkingMemory(agentName: string, wm: WorkingMemory): Promise { - const workingPath = this.getWorkingMemoryPath(agentName); - await this.ensureFolder(this.getMemoryDir(agentName)); - const content = serializeWorkingMemory(wm); - const existing = this.vault.getAbstractFileByPath(workingPath); - if (existing instanceof TFile) { - await this.vault.modify(existing, content); - } else { - await this.createFileIfMissing(workingPath, content); - } - - // Complete migration: retire the legacy flat file once v2 exists. - const legacyPath = this.getMemoryPath(agentName); - if (legacyPath !== workingPath && this.vault.getAbstractFileByPath(legacyPath)) { - await this.trashFile(legacyPath); - } + return this.memory.writeWorkingMemory(agentName, wm); } - /** - * One-time migration of an agent's legacy flat memory file to the v2 folder - * layout. Crucially, the legacy content is SEEDED INTO THE RAW ARCHIVE first, - * so "ground truth is never destroyed" (§ design overview, §13.1) holds even - * if the first reflection later summarizes the working copy. Without this, a - * reflection running before any capture could permanently drop pre-v2 memory. - * No-op when already migrated (working.md exists) or there is no legacy file. - */ + /** One-time legacy→v2 memory migration. See + * {@link MemoryStore.migrateLegacyMemory} for the raw-seeding and + * concurrent-caller (shared promise) semantics. */ async migrateLegacyMemory(agentName: string): Promise { - const workingPath = this.getWorkingMemoryPath(agentName); - if (this.vault.getAbstractFileByPath(workingPath)) return; // already v2 - const legacyPath = this.getMemoryPath(agentName); - const legacyFile = this.vault.getAbstractFileByPath(legacyPath); - if (!(legacyFile instanceof TFile)) return; - // Guard against a concurrent in-flight migration of the same agent (the - // reflection path calls this unlocked while a capture may run it under the - // MemoryWriter lock) — without it, both pass the existence check above and - // each seed the raw archive, duplicating the migration block. - if (this.migratingMemory.has(agentName)) return; - this.migratingMemory.add(agentName); - try { - const content = await this.vault.cachedRead(legacyFile); - const { body } = parseMarkdownWithFrontmatter>(content); - const nowIso = new Date().toISOString(); - - // 1. Seed the raw archive with the legacy content (permanent ground truth). - const seedLines = body - .split("\n") - .map((l) => l.replace(/^\s*[-*]\s+/, "").trim()) - .filter((l) => l.length > 0 && !/^#{1,6}\s/.test(l)); - const rawLines = [ - `- ${nowIso} [migrated] Imported ${seedLines.length} legacy memory ` + - `entr${seedLines.length === 1 ? "y" : "ies"} (pre-v2).`, - ...seedLines.map((t) => `- ${nowIso} [migrated] ${t}`), - ]; - await this.appendRawMemory(agentName, rawLines, nowIso); - - // 2. Write the v2 working memory; writeWorkingMemory trashes the legacy file. - const wm = migrateLegacyBody(body, workingPath, agentName, nowIso); - await this.writeWorkingMemory(agentName, wm); - } finally { - this.migratingMemory.delete(agentName); - } + return this.memory.migrateLegacyMemory(agentName); } /** Migrate every memory-enabled agent's legacy file once (called on load, - * before reflection jobs are registered). */ + * before reflection jobs are registered). Stays on the facade because it + * needs the loaded agent snapshot. */ async migrateAllLegacyMemory(): Promise { for (const agent of this.getSnapshot().agents) { if (!agent.memory) continue; try { - await this.migrateLegacyMemory(agent.name); + await this.memory.migrateLegacyMemory(agent.name); } catch (err) { console.warn(`Agent Fleet: legacy memory migration failed for "${agent.name}"`, err); } } } - /** Vault-relative path to the agent's MCP-tool capture inbox directory (§7.5). - * Each capture is written as its own JSON file inside it. */ + /** Vault-relative path to the agent's MCP-tool capture inbox directory (§7.5). */ getPendingDir(agentName: string): string { - return normalizePath(`${this.getMemoryDir(agentName)}/pending`); + return this.memory.getPendingDir(agentName); } /** Absolute on-disk path to the pending inbox dir, for the MCP server's env. * Null when the vault is not a local filesystem (e.g. mobile). */ getPendingDirAbsolutePath(agentName: string): string | null { - const adapter = this.vault.adapter; - if (!(adapter instanceof FileSystemAdapter)) return null; - return join(adapter.getBasePath(), this.getPendingDir(agentName)); + return this.memory.getPendingDirAbsolutePath(agentName); } /** Ensure the agent's memory folder exists. */ async ensureMemoryDir(agentName: string): Promise { - await this.ensureFolder(this.getMemoryDir(agentName)); + return this.memory.ensureMemoryDir(agentName); } - /** - * Drain the MCP-tool capture inbox: read every JSON file the external server - * wrote, delete each one, and return their lines. Goes through the raw vault - * ADAPTER (not the TFile cache) because those files are created out-of-process - * and may not be in Obsidian's metadata index yet. Reading + deleting whole - * files means we never truncate a file mid-append, so a capture landing during - * a drain is just picked up by the next drain — no read-then-clear loss. - * Caller serializes via the MemoryWriter lock. - */ + /** Drain the MCP-tool capture inbox. See + * {@link MemoryStore.readAndClearPending} for the no-loss semantics. */ async readAndClearPending(agentName: string): Promise { - const adapter = this.vault.adapter; - const dir = this.getPendingDir(agentName); - let files: string[]; - try { - if (!(await adapter.exists(dir))) return []; - files = (await adapter.list(dir)).files; - } catch { - return []; - } - const lines: string[] = []; - for (const filePath of files) { - if (!filePath.endsWith(".json")) continue; // ignore .tmp partials - try { - const content = await adapter.read(filePath); - // Remove BEFORE folding the lines in: if remove() fails we skip this - // file (retried next drain) rather than folding it now and re-folding - // the same capture next time — which would duplicate raw-archive lines. - await adapter.remove(filePath); - for (const l of content.split("\n")) if (l.trim().length > 0) lines.push(l); - } catch { - // skip an unreadable / not-yet-removable file (picked up next drain) - } - } - return lines; + return this.memory.readAndClearPending(agentName); } /** Read the recent raw archive (last `days` daily files) for reflection. */ async readRecentRaw(agentName: string, days = 2): Promise { - const dir = normalizePath(`${this.getMemoryDir(agentName)}/raw`); - const folder = this.vault.getAbstractFileByPath(dir); - if (!(folder instanceof TFolder)) return ""; - const files = folder.children - .filter((c): c is TFile => c instanceof TFile && c.extension === "md") - .sort((a, b) => b.name.localeCompare(a.name)) - .slice(0, days); - const parts: string[] = []; - for (const f of files.reverse()) { - parts.push(`### ${f.basename}\n${await this.vault.cachedRead(f)}`); - } - return parts.join("\n\n"); + return this.memory.readRecentRaw(agentName, days); } /** Path to the agent's skill-candidate ledger. */ getCandidatesPath(agentName: string): string { - return normalizePath(`${this.getMemoryDir(agentName)}/candidates.json`); + return this.memory.getCandidatesPath(agentName); } async readCandidates(agentName: string): Promise { - const path = this.getCandidatesPath(agentName); - const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return []; - try { - const parsed = JSON.parse(await this.vault.cachedRead(file)) as unknown; - return Array.isArray(parsed) ? (parsed as SkillCandidate[]) : []; - } catch { - return []; - } + return this.memory.readCandidates(agentName); } async writeCandidates(agentName: string, candidates: SkillCandidate[]): Promise { - const path = this.getCandidatesPath(agentName); - await this.ensureFolder(this.getMemoryDir(agentName)); - const content = JSON.stringify(candidates, null, 2); - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) await this.vault.modify(file, content); - else await this.createFileIfMissing(path, content); + return this.memory.writeCandidates(agentName, candidates); } // ═══════════════════════════════════════════════════════ - // Skill proposals (memory → skills, §9) + // Skill proposals (memory → skills, §9) — delegates to ProposalStore // ═══════════════════════════════════════════════════════ getProposalsDir(): string { - return normalizePath(`${this.getFleetRoot()}/proposals`); + return this.proposals.getProposalsDir(); } /** List all proposals, newest first. */ async listProposals(): Promise { - const folder = this.vault.getAbstractFileByPath(this.getProposalsDir()); - if (!(folder instanceof TFolder)) return []; - const out: SkillProposal[] = []; - for (const child of folder.children) { - if (!(child instanceof TFile) || child.extension !== "md") continue; - const p = await this.readProposal(child.basename); - if (p) out.push(p); - } - out.sort((a, b) => b.created.localeCompare(a.created)); - return out; + return this.proposals.listProposals(); } async readProposal(id: string): Promise { - const path = normalizePath(`${this.getProposalsDir()}/${id}.md`); - const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return null; - const { frontmatter, body } = parseMarkdownWithFrontmatter>( - await this.vault.cachedRead(file), - ); - const type = frontmatter.type === "skill_modify" ? "skill_modify" : "skill_create"; - const status = - frontmatter.status === "accepted" || frontmatter.status === "rejected" - ? frontmatter.status - : "pending"; - return { - id, - type, - agent: asString(frontmatter.agent) ?? "", - status, - created: asString(frontmatter.created) ?? "", - targetSkill: asString(frontmatter.target_skill), - candidate: asString(frontmatter.candidate), - evidence: asStringArray(frontmatter.evidence), - rationale: asString(frontmatter.rationale) ?? "", - body, - }; + return this.proposals.readProposal(id); } async writeProposal(p: SkillProposal): Promise { - await this.ensureFolder(this.getProposalsDir()); - const path = normalizePath(`${this.getProposalsDir()}/${p.id}.md`); - const fm: Record = { - id: p.id, - type: p.type, - agent: p.agent, - status: p.status, - created: p.created, - target_skill: p.targetSkill || undefined, - candidate: p.candidate || undefined, - evidence: p.evidence.length ? p.evidence : undefined, - rationale: p.rationale || undefined, - }; - const content = stringifyMarkdownWithFrontmatter(fm, p.body || ""); - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) await this.vault.modify(file, content); - else await this.createFileIfMissing(path, content); + return this.proposals.writeProposal(p); } async setProposalStatus(id: string, status: SkillProposal["status"]): Promise { - const p = await this.readProposal(id); - if (!p) return; - p.status = status; - await this.writeProposal(p); + return this.proposals.setProposalStatus(id, status); } /** Apply an accepted proposal: create the new skill (skill_create) or append * the proposed change to the target skill (skill_modify). Returns the path of * the affected skill, or null on no-op. */ async applyProposal(p: SkillProposal): Promise { - if (p.type === "skill_create") { - const name = p.targetSkill || `learned-${slugify(p.rationale).slice(0, 24) || "skill"}`; - const path = await this.getAvailablePath(this.getSubfolder("skills"), slugify(name)); - // Use the actual (de-duplicated) filename as the skill name so two - // proposals for the same pattern can't mint colliding skill names — - // getAvailablePath only de-dupes the file path, not the frontmatter name. - const finalName = path.split("/").pop()?.replace(/\.md$/, "") || slugify(name); - const fm = { - name: finalName, - description: p.rationale || `Auto-proposed from recurring pattern for ${p.agent}.`, - tags: ["proposed"], - }; - await this.vault.create(path, stringifyMarkdownWithFrontmatter(fm, p.body || "Skill instructions go here.")); - return path; - } - // skill_modify: append the proposed change to the target skill as an addendum. - if (p.targetSkill) { - const skill = this.getSkillByName(p.targetSkill); - if (skill) { - const file = this.vault.getAbstractFileByPath(skill.filePath); - if (file instanceof TFile) { - const existing = await this.vault.cachedRead(file); - const addendum = `\n\n## Proposed update (${new Date().toISOString().slice(0, 10)})\n${p.body}`; - await this.vault.modify(file, `${existing.trimEnd()}${addendum}\n`); - return skill.filePath; - } - } - } - return null; + return this.proposals.applyProposal(p); } async deleteProposal(id: string): Promise { - await this.trashFile(normalizePath(`${this.getProposalsDir()}/${id}.md`)); + return this.proposals.deleteProposal(id); } /** Append timestamped lines to the day's raw archive (§6.3). Ground truth — * append-only, never injected. Caller serializes via the MemoryWriter lock. */ async appendRawMemory(agentName: string, lines: string[], dateIso: string): Promise { - if (lines.length === 0) return; - const path = this.getRawMemoryPath(agentName, dateIso); - await this.ensureFolder(path.replace(/\/[^/]+$/, "")); - const block = lines.map((l) => l.trimEnd()).join("\n"); - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) { - const existing = await this.vault.cachedRead(file); - await this.vault.modify(file, `${existing.trimEnd()}\n${block}\n`); - } else { - await this.createFileIfMissing(path, `${block}\n`); - } + return this.memory.appendRawMemory(agentName, lines, dateIso); } // ═══════════════════════════════════════════════════════ - // Conversation registry (in-app multi-conversation) + // Conversation registry — delegates to ConversationStore // ═══════════════════════════════════════════════════════ - /** Folder that holds per-conversation JSON files for an agent. Folder - * agents nest under their own folder; flat agents share the memory dir. */ - private getConversationsDir(agent: AgentConfig): string { - if (agent.isFolder) { - const folderPath = agent.filePath.replace(/\/agent\.md$/, ""); - return normalizePath(`${folderPath}/conversations`); - } - const memoryDir = this.getMemoryPath(agent.name).replace(/\/[^/]+$/, ""); - return normalizePath(`${memoryDir}/${agent.name}-conversations`); - } - - /** Path to a conversation's state file. The id is slugified for safety - * on disk; callers should treat the original id as opaque. */ - private getConversationPath(agent: AgentConfig, conversationId: string): string { - const dir = this.getConversationsDir(agent); - const safeId = slugify(conversationId) || "conversation"; - return normalizePath(`${dir}/${safeId}.json`); - } - - /** List all conversations for an agent, sorted by lastActive desc. - * Scans the conversations/ folder; pre-feature chat.json files (if any) - * are intentionally NOT surfaced here — see the "no migration" decision - * in the conversation-feature design notes. */ + /** List all conversations for an agent, sorted by lastActive desc. */ async listConversations(agent: AgentConfig): Promise { - const out: ConversationMeta[] = []; - const dir = this.getConversationsDir(agent); - const folder = this.vault.getAbstractFileByPath(dir); - if (folder instanceof TFolder) { - for (const child of folder.children) { - if (!(child instanceof TFile) || child.extension !== "json") continue; - const id = child.basename; - const meta = await this.readConversationMeta(child, id); - if (meta) out.push(meta); - } - } - out.sort((a, b) => b.lastActive.localeCompare(a.lastActive)); - return out; + return this.conversations.listConversations(agent); } - private async readConversationMeta( - file: TFile, - id: string, - ): Promise { - try { - const content = await this.vault.cachedRead(file); - const state = JSON.parse(content) as { - name?: string; - lastActive?: string; - messages?: unknown[]; - }; - return { - id, - name: state.name?.trim() || "Untitled", - lastActive: state.lastActive ?? new Date(file.stat.mtime).toISOString(), - messageCount: Array.isArray(state.messages) ? state.messages.length : 0, - }; - } catch { - return null; - } - } - - /** Create an empty conversation file with a given id + name. Idempotent — - * if the file already exists, leaves it alone (callers can just switch - * into it). */ + /** Create an empty conversation file with a given id + name (idempotent). */ async createConversation(agent: AgentConfig, id: string, name: string): Promise { - const path = this.getConversationPath(agent, id); - const existing = this.vault.getAbstractFileByPath(path); - if (existing instanceof TFile) return; - const dir = path.replace(/\/[^/]+$/, ""); - await this.ensureFolder(dir); - const now = new Date().toISOString(); - const state = { - sessionId: null, - messages: [], - lastActive: now, - createdAt: now, - name: name.trim(), - }; - await this.vault.create(path, JSON.stringify(state, null, 2)); + return this.conversations.createConversation(agent, id, name); } /** Rename a conversation by writing a new `name` field into its state file. */ async renameConversation(agent: AgentConfig, conversationId: string, name: string): Promise { - const path = this.getConversationPath(agent, conversationId); - const file = this.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return; - const content = await this.vault.cachedRead(file); - let state: Record; - try { - state = JSON.parse(content) as Record; - } catch { - return; - } - state.name = name.trim(); - await this.vault.modify(file, JSON.stringify(state, null, 2)); + return this.conversations.renameConversation(agent, conversationId, name); } - /** Delete a conversation: the JSON file, its threads sidecar, and the - * parent `conversations/` folder if it just emptied out. The chat view - * auto-creates a fresh conversation if the user deletes their last one, - * so this never strands the panel in a no-conversations state. */ + /** Delete a conversation (file, threads sidecar, empty parent folder). */ async deleteConversation(agent: AgentConfig, conversationId: string): Promise { - const path = this.getConversationPath(agent, conversationId); - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) { - await this.app.fileManager.trashFile(file); - } - - const threadsDir = path.replace(/\.json$/, ".threads"); - const threadsFolder = this.vault.getAbstractFileByPath(threadsDir); - if (threadsFolder instanceof TFolder) { - await this.app.fileManager.trashFile(threadsFolder); - } - - const dir = this.getConversationsDir(agent); - const folder = this.vault.getAbstractFileByPath(dir); - if (folder instanceof TFolder && folder.children.length === 0) { - await this.app.fileManager.trashFile(folder); - } + return this.conversations.deleteConversation(agent, conversationId); } /** @@ -1212,1493 +430,145 @@ export class FleetRepository { * legacy flat file. New code should use {@link readWorkingMemory}. */ async getMemory(agentName: string): Promise { - const wm = await this.readWorkingMemory(agentName); - if (wm) { - return { - filePath: wm.filePath, - agent: wm.agent, - lastUpdated: wm.lastUpdated, - body: renderSections(wm.sections), - }; - } - return null; + return this.memory.getMemory(agentName); } + /** + * @deprecated Dead code — no remaining callers. See + * {@link MemoryStore.appendMemory} for why it must not be resurrected + * without MemoryWriter's lock. + */ async appendMemory(agentName: string, entries: string[]): Promise { - if (entries.length === 0) { - return; - } - - const path = this.getMemoryPath(agentName); - const file = this.vault.getAbstractFileByPath(path); - const timestamp = new Date().toISOString(); - const entryBlock = entries.map((entry) => `- ${entry.trim()}`).join("\n"); - - if (file instanceof TFile) { - const existing = await this.getMemory(agentName); - const body = `${existing?.body.trim() || "## Learned Context"}\n\n${entryBlock}`.trim(); - await this.vault.modify( - file, - stringifyMarkdownWithFrontmatter( - { - agent: agentName, - last_updated: timestamp, - }, - body, - ), - ); - return; - } - - await this.createFileIfMissing( - path, - stringifyMarkdownWithFrontmatter( - { - agent: agentName, - last_updated: timestamp, - }, - `## Learned Context\n\n${entryBlock}`, - ), - ); + return this.memory.appendMemory(agentName, entries); } + // ═══════════════════════════════════════════════════════ + // Run logs + usage ledger — delegates to RunLogStore / UsageLedger + // ═══════════════════════════════════════════════════════ + async listRecentRuns(limit = 50): Promise { - const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot()); - if (!(runsFolder instanceof TFolder)) { - return []; - } - - const files: TFile[] = []; - this.collectMarkdownChildren(runsFolder, files); - - files.sort((a, b) => b.path.localeCompare(a.path)); - const selected = files.slice(0, limit); - const parsed: RunLogData[] = []; - - for (const file of selected) { - const run = await this.readRunLog(file); - if (run) { - parsed.push(run); - } - } - - return parsed.sort((a, b) => b.started.localeCompare(a.started)); + return this.runLogs.listRecentRuns(limit); } - /** Return all runs whose date folder is on or after `sinceDate`. Used by - * the dashboard chart so a busy fleet can't push older days out of the - * visible window the way the count-capped `listRecentRuns` does. - * Walks only the relevant date subfolders, so cost is bounded by the - * window size, not the total number of historical runs. */ + /** Return all runs whose date folder is on or after `sinceDate`. See + * {@link RunLogStore.listRunsSince}. */ async listRunsSince(sinceDate: Date): Promise { - const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot()); - if (!(runsFolder instanceof TFolder)) { - return []; - } - - const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`; - const files: TFile[] = []; - for (const child of runsFolder.children) { - if (!(child instanceof TFolder)) continue; - // Date folders are named `YYYY-MM-DD`; lexicographic >= matches calendar >=. - if (child.name < sinceStr) continue; - this.collectMarkdownChildren(child, files); - } - - const parsed: RunLogData[] = []; - for (const file of files) { - const run = await this.readRunLog(file); - if (run) { - parsed.push(run); - } - } - - return parsed.sort((a, b) => b.started.localeCompare(a.started)); + return this.runLogs.listRunsSince(sinceDate); } - // ═══════════════════════════════════════════════════════ - // Usage ledger (chat/channel token+cost) — _fleet/usage/YYYY-MM-DD.jsonl - // ═══════════════════════════════════════════════════════ - - private usageLedgerPath(ts: string): string { - return normalizePath(`${this.getSubfolder("usage")}/${ts.slice(0, 10)}.jsonl`); - } - - /** Append one usage record to the day's JSONL ledger (one line per turn). - * Uses the raw adapter so it doesn't go through the markdown pipeline. */ + /** Append one usage record to the day's JSONL ledger (one line per turn). */ async appendUsage(record: UsageRecord): Promise { - await this.ensureFolder(this.getSubfolder("usage")); - const path = this.usageLedgerPath(record.ts); - const line = `${JSON.stringify(record)}\n`; - const adapter = this.vault.adapter; - if (await adapter.exists(path)) { - await adapter.append(path, line); - } else { - await adapter.write(path, line); - } + return this.usage.appendUsage(record); } /** Read all usage records on or after `sinceDate` (by ledger file date). */ async readUsageSince(sinceDate: Date): Promise { - const dir = this.getSubfolder("usage"); - const adapter = this.vault.adapter; - if (!(await adapter.exists(dir))) return []; - const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`; - const out: UsageRecord[] = []; - const listing = await adapter.list(dir); - for (const filePath of listing.files) { - if (!filePath.endsWith(".jsonl")) continue; - const base = (filePath.split("/").pop() ?? "").replace(/\.jsonl$/, ""); - // Files are named YYYY-MM-DD; lexicographic >= matches calendar >=. - if (base < sinceStr) continue; - let content: string; - try { - content = await adapter.read(filePath); - } catch { - continue; - } - for (const raw of content.split("\n")) { - const trimmed = raw.trim(); - if (!trimmed) continue; - try { - out.push(JSON.parse(trimmed) as UsageRecord); - } catch { - // skip a corrupt line rather than failing the whole read - } - } - } - return out; + return this.usage.readUsageSince(sinceDate); } - /** - * One-time repair of historical usage rows that stored Claude's CUMULATIVE - * `total_cost_usd` as a per-turn cost (see `deltaizeCumulativeCosts`). Reads - * every `*.jsonl` ledger file, reconstructs per-turn costs per agent across - * the whole ledger (a process can't be assumed to stay within one day), and - * rewrites each file in place. Guarded by a marker file so it runs at most - * once; idempotent and fail-soft (any error leaves the ledger untouched). - */ + /** One-time cumulative→per-turn cost repair. See + * {@link UsageLedger.migrateUsageLedgerCosts}. */ async migrateUsageLedgerCosts(): Promise<{ files: number; rows: number; changed: number } | null> { - const dir = this.getSubfolder("usage"); - const adapter = this.vault.adapter; - if (!(await adapter.exists(dir))) return null; - const marker = normalizePath(`${dir}/.cost-delta-v1`); - if (await adapter.exists(marker)) return null; - - try { - const listing = await adapter.list(dir); - const files: Array<{ path: string; records: UsageRecord[] }> = []; - const all: UsageRecord[] = []; - for (const filePath of listing.files) { - if (!filePath.endsWith(".jsonl")) continue; - let content: string; - try { - content = await adapter.read(filePath); - } catch { - continue; - } - const records: UsageRecord[] = []; - for (const raw of content.split("\n")) { - const trimmed = raw.trim(); - if (!trimmed) continue; - try { - records.push(JSON.parse(trimmed) as UsageRecord); - } catch { - // skip a corrupt line rather than failing the whole migration - } - } - files.push({ path: filePath, records }); - all.push(...records); - } - - // Mutates `costUsd` on the same objects held in `files[].records`. - const changed = deltaizeCumulativeCosts(all); - - if (changed > 0) { - for (const { path, records } of files) { - if (records.length === 0) continue; - const body = records.map((r) => JSON.stringify(r)).join("\n") + "\n"; - await adapter.write(path, body); - } - } - await adapter.write(marker, `migrated ${all.length} rows; corrected ${changed}\n`); - return { files: files.length, rows: all.length, changed }; - } catch (err) { - console.error("Agent Fleet: usage-ledger cost migration failed (ledger left untouched)", err); - return null; - } + return this.usage.migrateUsageLedgerCosts(); } async readRunLog(file: TFile): Promise { - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - // `## Prompt` terminates at either `## Result` (new format) or - // `## Output` (legacy format). `## Result` is optional and missing on - // runs created before finalResult was extracted — those runs fall back - // to rendering `output` in the panel. - const promptMatch = body.match(/## Prompt\n([\s\S]*?)(?:\n## Result\n|\n## Output\n|$)/); - const resultMatch = body.match(/## Result\n([\s\S]*?)(?:\n## Output\n|$)/); - const outputMatch = body.match(/## Output\n([\s\S]*?)(?:\n## Tools Used\n|$)/); - const toolsMatch = body.match(/## Tools Used\n([\s\S]*?)(?:\n## STDERR\n|$)/); - return { - filePath: file.path, - runId: asString(frontmatter.run_id) ?? file.basename, - agent: asString(frontmatter.agent) ?? "unknown", - task: asString(frontmatter.task) ?? "unknown", - status: (asString(frontmatter.status) as RunLogData["status"]) ?? "failure", - started: asString(frontmatter.started) ?? new Date(file.stat.ctime).toISOString(), - completed: asString(frontmatter.completed), - durationSeconds: asNumber(frontmatter.duration_seconds, 0), - tokensUsed: typeof frontmatter.tokens_used === "number" ? frontmatter.tokens_used : undefined, - costUsd: typeof frontmatter.cost_usd === "number" ? frontmatter.cost_usd : undefined, - model: asString(frontmatter.model) ?? DEFAULT_SETTINGS.defaultModel, - modelSource: ((): RunLogData["modelSource"] => { - const raw = asString(frontmatter.model_source); - if (raw === "task" || raw === "agent" || raw === "settings" || raw === "cli-default") return raw; - return undefined; - })(), - concreteModel: asString(frontmatter.resolved_concrete_model), - exitCode: typeof frontmatter.exit_code === "number" ? frontmatter.exit_code : null, - tags: asStringArray(frontmatter.tags), - prompt: promptMatch?.[1]?.trim() ?? "", - output: outputMatch?.[1]?.trim() ?? "", - finalResult: resultMatch?.[1]?.trim() || undefined, - toolsUsed: toolsMatch?.[1] - ? splitLines(toolsMatch[1]) - .map((line) => line.replace(/^- /, "").trim()) - .filter(Boolean) - : [], - approvals: this.parseApprovals(frontmatter.approvals), - }; + return this.runLogs.readRunLog(file); } async writeRunLog(run: RunLogData): Promise { - const started = new Date(run.started); - const dateFolder = normalizePath(`${this.getRunsRoot()}/${started.toISOString().slice(0, 10)}`); - await this.ensureFolder(dateFolder); - const filename = `${started.toISOString().slice(11, 19).replace(/:/g, "")}-${slugify(run.agent)}-${slugify(run.task)}.md`; - const path = normalizePath(`${dateFolder}/${filename}`); - const content = stringifyMarkdownWithFrontmatter( - { - run_id: run.runId, - agent: run.agent, - task: run.task, - status: run.status, - started: run.started, - completed: run.completed, - duration_seconds: run.durationSeconds, - tokens_used: run.tokensUsed, - cost_usd: run.costUsd, - model: run.model, - model_source: run.modelSource, - resolved_concrete_model: run.concreteModel, - exit_code: run.exitCode, - tags: run.tags, - approvals: run.approvals, - }, - [ - "## Prompt", - "", - run.prompt.trim(), - "", - // `## Result` carries the final answer without narration, from the - // CLI's `type: "result"` event. Omitted when absent so legacy-format - // run files stay identical. - ...(run.finalResult && run.finalResult.trim() - ? ["## Result", "", run.finalResult.trim(), ""] - : []), - "## Output", - "", - run.output.trim() || "(no output)", - "", - "## Tools Used", - "", - ...(run.toolsUsed.length > 0 ? run.toolsUsed.map((tool) => `- ${tool}`) : ["- none"]), - ...(run.stderr ? ["", "## STDERR", "", run.stderr.trim()] : []), - ].join("\n"), - ); - - const existing = this.vault.getAbstractFileByPath(path); - if (existing instanceof TFile) { - await this.vault.modify(existing, content); - } else { - await this.vault.create(path, content); - } - return path; - } - - async updateTaskRunMetadata(task: TaskConfig, updates: Partial>): Promise { - const file = this.vault.getAbstractFileByPath(task.filePath); - if (!(file instanceof TFile)) { - return; - } - - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - const nextFrontmatter = { - ...frontmatter, - last_run: updates.lastRun ?? task.lastRun, - next_run: updates.nextRun ?? task.nextRun, - run_count: updates.runCount ?? task.runCount, - }; - - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(nextFrontmatter, body)); - await this.loadFile(file); + return this.runLogs.writeRunLog(run); } async setApprovalDecision(runPath: string, tool: string, decision: "approved" | "rejected"): Promise { - const file = this.vault.getAbstractFileByPath(runPath); - if (!(file instanceof TFile)) { - return; - } + return this.runLogs.setApprovalDecision(runPath, tool, decision); + } - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - const approvals = (this.parseApprovals(frontmatter.approvals) ?? []).map((approval) => - approval.tool === tool - ? { - ...approval, - status: decision, - resolvedAt: new Date().toISOString(), - } - : approval, - ); + // ═══════════════════════════════════════════════════════ + // Entity create/update/delete — delegates to EntityMutations + // ═══════════════════════════════════════════════════════ - await this.vault.modify( - file, - stringifyMarkdownWithFrontmatter( - { - ...frontmatter, - approvals, - }, - body, - ), - ); + async updateTaskRunMetadata(task: TaskConfig, updates: Partial>): Promise { + return this.mutations.updateTaskRunMetadata(task, updates); } async createAgentTemplate(name: string): Promise { - const path = await this.getAvailablePath(this.getSubfolder("agents"), slugify(name)); - const content = `---\nname: ${slugify(name)}\ndescription: \nenabled: true\nskills: []\ntags: []\n---\n\nAgent instructions go here.\n`; - return await this.vault.create(path, content); + return this.mutations.createAgentTemplate(name); } - async createAgentFolder(opts: { - name: string; - description: string; - avatar: string; - tags: string[]; - systemPrompt: string; - model: string; - adapter: string; - cwd: string; - timeout: number; - permissionMode: string; - effort?: string; - approvalRequired: string[]; - memory: boolean; - memoryMaxEntries: number; - skills: string[]; - mcpServers?: string[]; - skillsBody: string; - contextBody: string; - enabled?: boolean; - permissionRules?: { allow: string[]; deny: string[] }; - autoCompactThreshold?: number; - wikiReferences?: string[]; - }): Promise { - const slug = slugify(opts.name); - const folderPath = normalizePath(`${this.getSubfolder("agents")}/${slug}`); - await this.ensureFolder(folderPath); - - // agent.md - const agentFm: Record = { - name: opts.name, - description: opts.description || undefined, - avatar: opts.avatar || undefined, - enabled: opts.enabled ?? true, - tags: opts.tags, - skills: opts.skills, - mcp_servers: opts.mcpServers?.length ? opts.mcpServers : undefined, - }; - if (opts.model && opts.model !== "default") { - agentFm.model = opts.model; - } - const agentPath = normalizePath(`${folderPath}/agent.md`); - await this.vault.create(agentPath, stringifyMarkdownWithFrontmatter(agentFm, opts.systemPrompt || "")); - - // config.md - const configFm: Record = { - model: opts.model || "default", - adapter: opts.adapter || "claude-code", - timeout: opts.timeout, - max_retries: 1, - cwd: opts.cwd || "", - permission_mode: opts.permissionMode || "bypassPermissions", - effort: opts.effort || undefined, - approval_required: opts.approvalRequired, - memory: opts.memory, - memory_max_entries: opts.memoryMaxEntries, - }; - if (typeof opts.autoCompactThreshold === "number") { - configFm.auto_compact_threshold = opts.autoCompactThreshold; - } - if (opts.wikiReferences && opts.wikiReferences.length > 0) { - configFm.wiki_references = opts.wikiReferences.map((agent) => ({ agent })); - } - const configPath = normalizePath(`${folderPath}/config.md`); - await this.vault.create(configPath, stringifyMarkdownWithFrontmatter(configFm, "")); - - // SKILLS.md - const skillsPath = normalizePath(`${folderPath}/SKILLS.md`); - await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, opts.skillsBody || "")); - - // CONTEXT.md - const contextPath = normalizePath(`${folderPath}/CONTEXT.md`); - await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, opts.contextBody || "")); - - // permissions.json (only if rules are non-empty) - const rules = opts.permissionRules; - if (rules && (rules.allow.length > 0 || rules.deny.length > 0)) { - const permPath = normalizePath(`${folderPath}/permissions.json`); - await this.vault.create(permPath, JSON.stringify(rules, null, 2) + "\n"); - } - - return agentPath; + async createAgentFolder(opts: CreateAgentFolderOptions): Promise { + return this.mutations.createAgentFolder(opts); } async createSkillTemplate(name: string): Promise { - const path = await this.getAvailablePath(this.getSubfolder("skills"), slugify(name)); - const content = `---\nname: ${slugify(name)}\ndescription: \ntags: []\n---\n\nSkill instructions go here.\n`; - return await this.vault.create(path, content); + return this.mutations.createSkillTemplate(name); } - async createSkillFolder(opts: { - name: string; - description: string; - tags: string[]; - body: string; - toolsBody: string; - referencesBody: string; - examplesBody: string; - }): Promise { - const folderPath = normalizePath(`${this.getSubfolder("skills")}/${slugify(opts.name)}`); - await this.ensureFolder(folderPath); - - const skillFm = { - name: opts.name, - description: opts.description || undefined, - tags: opts.tags.length > 0 ? opts.tags : undefined, - }; - const skillPath = normalizePath(`${folderPath}/skill.md`); - await this.createFileIfMissing(skillPath, stringifyMarkdownWithFrontmatter(skillFm, opts.body || "Skill instructions go here.")); - - if (opts.toolsBody) { - const toolsPath = normalizePath(`${folderPath}/tools.md`); - await this.createFileIfMissing(toolsPath, `# Tools\n\n${opts.toolsBody}`); - } - - if (opts.referencesBody) { - const refsPath = normalizePath(`${folderPath}/references.md`); - await this.createFileIfMissing(refsPath, `# References\n\n${opts.referencesBody}`); - } - - if (opts.examplesBody) { - const examplesPath = normalizePath(`${folderPath}/examples.md`); - await this.createFileIfMissing(examplesPath, `# Examples\n\n${opts.examplesBody}`); - } + async createSkillFolder(opts: CreateSkillFolderOptions): Promise { + return this.mutations.createSkillFolder(opts); } - async updateAgent(agentName: string, updates: { - description?: string; - avatar?: string; - tags?: string[]; - systemPrompt?: string; - model?: string; - adapter?: string; - cwd?: string; - timeout?: number; - permissionMode?: string; - effort?: string; - permissionRules?: { allow: string[]; deny: string[] }; - approvalRequired?: string[]; - memory?: boolean; - memoryTokenBudget?: number; - reflectionEnabled?: boolean; - reflectionSchedule?: string; - reflectionProposeSkills?: boolean; - skills?: string[]; - mcpServers?: string[]; - skillsBody?: string; - contextBody?: string; - enabled?: boolean; - /** 0-100; percent of context window at which auto-compact fires. */ - autoCompactThreshold?: number; - /** Names of Wiki Keeper agents this agent can read from. */ - wikiReferences?: string[]; - }): Promise { - const agent = this.getAgentByName(agentName); - if (!agent) return; - - if (agent.isFolder) { - const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, "")); - - // Update agent.md - const agentFile = this.vault.getAbstractFileByPath(agent.filePath); - if (agentFile instanceof TFile) { - const content = await this.vault.cachedRead(agentFile); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.description !== undefined) frontmatter.description = updates.description || undefined; - if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined; - if (updates.tags !== undefined) frontmatter.tags = updates.tags; - if (updates.skills !== undefined) frontmatter.skills = updates.skills; - if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined; - if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; - if (updates.model !== undefined && updates.model !== "default") frontmatter.model = updates.model; - const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body; - await this.vault.modify(agentFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); - } - - // Update config.md - const configPath = normalizePath(`${folderPath}/config.md`); - const configFile = this.vault.getAbstractFileByPath(configPath); - if (configFile instanceof TFile) { - const content = await this.vault.cachedRead(configFile); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.model !== undefined) frontmatter.model = updates.model; - if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter; - if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout; - if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd; - if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode; - if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined; - if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired; - if (updates.memory !== undefined) frontmatter.memory = updates.memory; - if (updates.memoryTokenBudget !== undefined) frontmatter.memory_token_budget = updates.memoryTokenBudget; - if (updates.reflectionEnabled !== undefined) frontmatter.reflection_enabled = updates.reflectionEnabled; - if (updates.reflectionSchedule !== undefined) frontmatter.reflection_schedule = updates.reflectionSchedule; - if (updates.reflectionProposeSkills !== undefined) frontmatter.reflection_propose_skills = updates.reflectionProposeSkills; - if (updates.autoCompactThreshold !== undefined) { - frontmatter.auto_compact_threshold = updates.autoCompactThreshold; - } - if (updates.wikiReferences !== undefined) { - frontmatter.wiki_references = updates.wikiReferences.length > 0 - ? updates.wikiReferences.map((agent) => ({ agent })) - : undefined; - } - // Strip dead legacy permission fields whenever we touch config.md — - // permissions.json is now the canonical surface for allow/deny. - delete frontmatter.allowed_tools; - delete frontmatter.blocked_tools; - await this.vault.modify(configFile, stringifyMarkdownWithFrontmatter(frontmatter, body)); - } - - // Update SKILLS.md - if (updates.skillsBody !== undefined) { - const skillsPath = normalizePath(`${folderPath}/SKILLS.md`); - const skillsFile = this.vault.getAbstractFileByPath(skillsPath); - if (skillsFile instanceof TFile) { - await this.vault.modify(skillsFile, stringifyMarkdownWithFrontmatter({}, updates.skillsBody)); - } else { - await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, updates.skillsBody)); - } - } - - // Update CONTEXT.md - if (updates.contextBody !== undefined) { - const contextPath = normalizePath(`${folderPath}/CONTEXT.md`); - const contextFile = this.vault.getAbstractFileByPath(contextPath); - if (contextFile instanceof TFile) { - await this.vault.modify(contextFile, stringifyMarkdownWithFrontmatter({}, updates.contextBody)); - } else { - await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, updates.contextBody)); - } - } - - // Update permissions.json - if (updates.permissionRules !== undefined) { - const permPath = normalizePath(`${folderPath}/permissions.json`); - const permFile = this.vault.getAbstractFileByPath(permPath); - const rules = updates.permissionRules; - if (rules.allow.length > 0 || rules.deny.length > 0) { - const content = JSON.stringify(rules, null, 2) + "\n"; - if (permFile instanceof TFile) { - await this.vault.modify(permFile, content); - } else { - await this.vault.create(permPath, content); - } - } else if (permFile instanceof TFile) { - // Remove permissions.json if both lists are empty - await this.app.fileManager.trashFile(permFile); - } - } - } else { - // Legacy single-file agent - const file = this.vault.getAbstractFileByPath(agent.filePath); - if (!(file instanceof TFile)) return; - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.description !== undefined) frontmatter.description = updates.description || undefined; - if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined; - if (updates.tags !== undefined) frontmatter.tags = updates.tags; - if (updates.skills !== undefined) frontmatter.skills = updates.skills; - if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined; - if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; - if (updates.model !== undefined) frontmatter.model = updates.model; - if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter; - if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout; - if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd; - if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode; - if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined; - if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired; - if (updates.memory !== undefined) frontmatter.memory = updates.memory; - if (updates.autoCompactThreshold !== undefined) { - frontmatter.auto_compact_threshold = updates.autoCompactThreshold; - } - if (updates.wikiReferences !== undefined) { - frontmatter.wiki_references = updates.wikiReferences.length > 0 - ? updates.wikiReferences.map((agent) => ({ agent })) - : undefined; - } - // Strip dead legacy permission fields whenever we touch the file — - // permissions.json sidecar is now the canonical surface. - delete frontmatter.allowed_tools; - delete frontmatter.blocked_tools; - const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body; - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); - - // Sidecar permissions.json for flat agents — parity with folder agents. - if (updates.permissionRules !== undefined) { - const sidecarPath = sidecarPermissionsPath(agent.filePath); - const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath); - const rules = updates.permissionRules; - if (rules.allow.length > 0 || rules.deny.length > 0) { - const content = JSON.stringify(rules, null, 2) + "\n"; - if (sidecarFile instanceof TFile) { - await this.vault.modify(sidecarFile, content); - } else { - await this.vault.create(sidecarPath, content); - } - } else if (sidecarFile instanceof TFile) { - await this.app.fileManager.trashFile(sidecarFile); - } - } - } + async updateAgent(agentName: string, updates: AgentUpdates): Promise { + return this.mutations.updateAgent(agentName, updates); } - async updateTask(taskId: string, updates: { - agent?: string; - type?: string; - schedule?: string; - runAt?: string; - enabled?: boolean; - priority?: string; - catch_up?: boolean; - effort?: string; - model?: string; - channel?: string; - channelTarget?: string; - tags?: string[]; - body?: string; - }): Promise { - const task = this.getTaskById(taskId); - if (!task) return; - - const file = this.vault.getAbstractFileByPath(task.filePath); - if (!(file instanceof TFile)) return; - - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.agent !== undefined) frontmatter.agent = updates.agent; - if (updates.type !== undefined) frontmatter.type = updates.type; - if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined; - if (updates.runAt !== undefined) frontmatter.run_at = updates.runAt || undefined; - if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; - if (updates.priority !== undefined) frontmatter.priority = updates.priority; - if (updates.catch_up !== undefined) frontmatter.catch_up = updates.catch_up; - if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined; - if (updates.model !== undefined) frontmatter.model = updates.model || undefined; - if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined; - if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined; - if (updates.tags !== undefined) frontmatter.tags = updates.tags; - const newBody = updates.body !== undefined ? updates.body : body; - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + async updateTask(taskId: string, updates: TaskUpdates): Promise { + return this.mutations.updateTask(taskId, updates); } - async updateSkill(skillName: string, updates: { - description?: string; - tags?: string[]; - body?: string; - toolsBody?: string; - referencesBody?: string; - examplesBody?: string; - }): Promise { - const skill = this.getSkillByName(skillName); - if (!skill) return; - - if (skill.isFolder) { - const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, "")); - - // Update skill.md - const skillFile = this.vault.getAbstractFileByPath(skill.filePath); - if (skillFile instanceof TFile) { - const content = await this.vault.cachedRead(skillFile); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.description !== undefined) frontmatter.description = updates.description || undefined; - if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined; - const newBody = updates.body !== undefined ? updates.body : body; - await this.vault.modify(skillFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); - } - - // Update tools.md - if (updates.toolsBody !== undefined) { - const toolsPath = normalizePath(`${folderPath}/tools.md`); - const toolsFile = this.vault.getAbstractFileByPath(toolsPath); - if (updates.toolsBody) { - if (toolsFile instanceof TFile) { - await this.vault.modify(toolsFile, `# Tools\n\n${updates.toolsBody}`); - } else { - await this.vault.create(toolsPath, `# Tools\n\n${updates.toolsBody}`); - } - } - } - - // Update references.md - if (updates.referencesBody !== undefined) { - const refsPath = normalizePath(`${folderPath}/references.md`); - const refsFile = this.vault.getAbstractFileByPath(refsPath); - if (updates.referencesBody) { - if (refsFile instanceof TFile) { - await this.vault.modify(refsFile, `# References\n\n${updates.referencesBody}`); - } else { - await this.vault.create(refsPath, `# References\n\n${updates.referencesBody}`); - } - } - } - - // Update examples.md - if (updates.examplesBody !== undefined) { - const examplesPath = normalizePath(`${folderPath}/examples.md`); - const examplesFile = this.vault.getAbstractFileByPath(examplesPath); - if (updates.examplesBody) { - if (examplesFile instanceof TFile) { - await this.vault.modify(examplesFile, `# Examples\n\n${updates.examplesBody}`); - } else { - await this.vault.create(examplesPath, `# Examples\n\n${updates.examplesBody}`); - } - } - } - } else { - // Legacy single-file skill - const file = this.vault.getAbstractFileByPath(skill.filePath); - if (!(file instanceof TFile)) return; - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.description !== undefined) frontmatter.description = updates.description || undefined; - if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined; - const newBody = updates.body !== undefined ? updates.body : body; - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); - } + async updateSkill(skillName: string, updates: SkillUpdates): Promise { + return this.mutations.updateSkill(skillName, updates); } async deleteSkill(skillName: string): Promise { - const skill = this.getSkillByName(skillName); - if (!skill) return; - - if (skill.isFolder) { - const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, "")); - const folder = this.vault.getAbstractFileByPath(folderPath); - if (folder instanceof TFolder) { - await this.app.fileManager.trashFile(folder); - } - } else { - await this.trashFile(skill.filePath); - } + return this.mutations.deleteSkill(skillName); } async deleteTask(taskId: string): Promise { - const task = this.getTaskById(taskId); - if (!task) return; - await this.trashFile(task.filePath); + return this.mutations.deleteTask(taskId); } - async updateChannel(channelName: string, updates: { - default_agent?: string; - allowed_agents?: string[]; - enabled?: boolean; - credential_ref?: string; - allowed_users?: string[]; - per_user_sessions?: boolean; - channel_context?: string; - tags?: string[]; - body?: string; - type?: string; - transport?: Record; - }): Promise { - const channel = this.getChannelByName(channelName); - if (!channel) return; - - const file = this.vault.getAbstractFileByPath(channel.filePath); - if (!(file instanceof TFile)) return; - - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.default_agent !== undefined) { - frontmatter.default_agent = updates.default_agent; - // Remove legacy `agent` field to avoid confusion - delete frontmatter.agent; - } - if (updates.allowed_agents !== undefined) frontmatter.allowed_agents = updates.allowed_agents; - if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; - if (updates.credential_ref !== undefined) frontmatter.credential_ref = updates.credential_ref; - if (updates.allowed_users !== undefined) frontmatter.allowed_users = updates.allowed_users; - if (updates.per_user_sessions !== undefined) frontmatter.per_user_sessions = updates.per_user_sessions; - if (updates.channel_context !== undefined) frontmatter.channel_context = updates.channel_context || undefined; - if (updates.tags !== undefined) frontmatter.tags = updates.tags; - if (updates.type !== undefined) frontmatter.type = updates.type; - if (updates.transport !== undefined) frontmatter.transport = updates.transport; - const newBody = updates.body !== undefined ? updates.body : body; - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + async updateChannel(channelName: string, updates: ChannelUpdates): Promise { + return this.mutations.updateChannel(channelName, updates); } async deleteChannel(channelName: string): Promise { - const channel = this.getChannelByName(channelName); - if (!channel) return; - await this.trashFile(channel.filePath); - // Also trash the channel's sessions folder if it exists - const sessionsFolder = normalizePath( - `${this.getSubfolder("channels")}/${slugify(channelName)}/sessions`, - ); - const folder = this.vault.getAbstractFileByPath(sessionsFolder); - if (folder instanceof TFolder) { - await this.app.fileManager.trashFile(folder); - } + return this.mutations.deleteChannel(channelName); } - /** Build `_fleet/mcp/.md` frontmatter from a server definition, - * omitting empty/runtime fields. Secrets are never written here. */ - private mcpServerFrontmatter(server: McpServer): Record { - const fm: Record = { - name: server.name, - transport: server.type, - enabled: server.enabled, - }; - if (server.source) fm.source = server.source; - if (server.type === "stdio") { - if (server.command) fm.command = server.command; - if (server.args && server.args.length > 0) fm.args = server.args; - if (server.env && Object.keys(server.env).length > 0) fm.env = server.env; - if (server.envSecretKeys && server.envSecretKeys.length > 0) fm.env_secret_keys = server.envSecretKeys; - } else { - if (server.url) fm.url = server.url; - if (server.headers && Object.keys(server.headers).length > 0) fm.headers = server.headers; - if (server.auth) fm.auth = server.auth; - if (server.oauth && (server.oauth.clientId || server.oauth.resource || (server.oauth.scopes?.length ?? 0) > 0)) { - fm.oauth = { - client_id: server.oauth.clientId || undefined, - resource: server.oauth.resource || undefined, - scopes: server.oauth.scopes && server.oauth.scopes.length > 0 ? server.oauth.scopes : undefined, - }; - } - } - return fm; + async updateHeartbeat(agentName: string, updates: HeartbeatUpdates): Promise { + return this.mutations.updateHeartbeat(agentName, updates); } - /** - * Create or update an MCP server registry file. When `server.filePath` is set - * the existing file is rewritten in place; otherwise a new - * `_fleet/mcp/.md` is created. Returns the file path. Secrets are NOT - * handled here — callers store tokens/secret env values in SecretStore. - */ + async deleteAgent(agentName: string, deleteTasks: boolean): Promise<{ trashedFiles: string[] }> { + return this.mutations.deleteAgent(agentName, deleteTasks); + } + + // ═══════════════════════════════════════════════════════ + // MCP server registry — delegates to McpRegistry + // ═══════════════════════════════════════════════════════ + + /** Create or update an MCP server registry file. See + * {@link McpRegistry.saveMcpServer}. */ async saveMcpServer(server: McpServer, body = ""): Promise { - const fm = this.mcpServerFrontmatter(server); - const content = stringifyMarkdownWithFrontmatter(fm, body.trim()); - const existing = server.filePath ? this.vault.getAbstractFileByPath(server.filePath) : null; - if (existing instanceof TFile) { - await this.vault.modify(existing, content); - return existing.path; - } - const path = await this.getAvailablePath(this.getSubfolder("mcp"), slugify(server.name)); - await this.ensureFolder(this.getSubfolder("mcp")); - await this.vault.create(path, content); - return path; + return this.mcp.saveMcpServer(server, body); } /** Flip an MCP server's `enabled` flag in its registry file. */ async setMcpServerEnabled(name: string, enabled: boolean): Promise { - const server = this.getMcpServerByName(name); - if (!server?.filePath) return; - const file = this.vault.getAbstractFileByPath(server.filePath); - if (!(file instanceof TFile)) return; - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - frontmatter.enabled = enabled; - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, body)); + return this.mcp.setMcpServerEnabled(name, enabled); } /** Trash an MCP server's registry file. */ async deleteMcpServer(name: string): Promise { - const server = this.getMcpServerByName(name); - if (!server?.filePath) return; - await this.trashFile(server.filePath); - } - - async updateHeartbeat(agentName: string, updates: { - enabled?: boolean; - schedule?: string; - notify?: boolean; - channel?: string; - channelTarget?: string; - body?: string; - }): Promise { - const agent = this.getAgentByName(agentName); - if (!agent || !agent.isFolder) return; - - const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, "")); - const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`); - const file = this.vault.getAbstractFileByPath(heartbeatPath); - - if (file instanceof TFile) { - // Update existing file - const content = await this.vault.cachedRead(file); - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; - if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined; - if (updates.notify !== undefined) frontmatter.notify = updates.notify; - if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined; - if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined; - const newBody = updates.body !== undefined ? updates.body : body; - await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); - } else { - // Create new HEARTBEAT.md - const frontmatter: Record = { - enabled: updates.enabled ?? false, - }; - if (updates.schedule) frontmatter.schedule = updates.schedule; - if (updates.notify !== undefined) frontmatter.notify = updates.notify; - if (updates.channel) frontmatter.channel = updates.channel; - if (updates.channelTarget) frontmatter.channel_target = updates.channelTarget; - const body = updates.body ?? ""; - await this.vault.create( - heartbeatPath, - stringifyMarkdownWithFrontmatter(frontmatter, body), - ); - } - } - - async deleteAgent(agentName: string, deleteTasks: boolean): Promise<{ trashedFiles: string[] }> { - const trashedFiles: string[] = []; - const agent = this.getAgentByName(agentName); - if (!agent) return { trashedFiles }; - - if (agent.isFolder) { - // Trash the entire agent folder - const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, "")); - const folder = this.vault.getAbstractFileByPath(folderPath); - if (folder instanceof TFolder) { - const files: TFile[] = []; - this.collectMarkdownChildren(folder, files); - // Trash all files inside the folder first, then the folder - for (const f of files) { - trashedFiles.push(f.path); - } - await this.app.fileManager.trashFile(folder); - } - } else { - // Trash the single agent definition file - await this.trashFile(agent.filePath); - trashedFiles.push(agent.filePath); - } - - // Trash the agent's memory: legacy flat file + v2 memory folder. - const memoryPath = this.getMemoryPath(agentName); - if (this.vault.getAbstractFileByPath(memoryPath)) { - await this.trashFile(memoryPath); - trashedFiles.push(memoryPath); - } - const memoryDir = this.getMemoryDir(agentName); - const memoryFolder = this.vault.getAbstractFileByPath(memoryDir); - if (memoryFolder instanceof TFolder) { - await this.app.fileManager.trashFile(memoryFolder); - trashedFiles.push(memoryDir); - } - - // Optionally trash associated tasks - if (deleteTasks) { - const tasks = this.getTasksForAgent(agentName); - for (const task of tasks) { - await this.trashFile(task.filePath); - trashedFiles.push(task.filePath); - } - } - - return { trashedFiles }; + return this.mcp.deleteMcpServer(name); } async trashFile(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file) { - await this.app.fileManager.trashFile(file); - } - } - - private async ensureFolder(path: string): Promise { - if (this.vault.getAbstractFileByPath(path)) { - return; - } - try { - await this.vault.createFolder(path); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("Folder already exists")) { - throw error; - } - } + await trashPath(this.app, path); } async getAvailablePath(folder: string, baseName: string): Promise { - let attempt = 0; - while (true) { - const suffix = attempt === 0 ? "" : `-${attempt + 1}`; - const candidate = normalizePath(`${folder}/${baseName}${suffix}.md`); - if (!this.vault.getAbstractFileByPath(candidate)) { - return candidate; - } - attempt += 1; - } - } - - private async createFileIfMissing(path: string, content: string): Promise { - if (this.vault.getAbstractFileByPath(path)) { - return; - } - - try { - await this.vault.create(path, content); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("File already exists")) { - throw error; - } - } - } - - private collectMarkdownChildren(folder: TFolder, acc: TFile[]): void { - for (const child of folder.children) { - if (child instanceof TFile && child.extension === "md") { - acc.push(child); - } - if (child instanceof TFolder) { - this.collectMarkdownChildren(child, acc); - } - } - } - - private clearStoredFile(path: string): void { - this.agents.delete(path); - this.skills.delete(path); - this.tasks.delete(path); - this.channels.delete(path); - this.mcpServers.delete(path); - this.validationIssues.delete(path); - } - - private setIssue(path: string, message: string): void { - const issues = this.validationIssues.get(path) ?? []; - issues.push({ path, message }); - this.validationIssues.set(path, issues); - } - - private parseFile(path: string, content: string): ParsedEntity | null { - if (path.startsWith(`${this.getSubfolder("agents")}/`)) { - return this.parseAgent(path, content); - } - if (path.startsWith(`${this.getSubfolder("skills")}/`)) { - return this.parseSkill(path, content); - } - if (path.startsWith(`${this.getSubfolder("tasks")}/`)) { - return this.parseTask(path, content); - } - return null; - } - - private parseAgent(path: string, content: string): AgentConfig | null { - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - if (!isRecord(frontmatter)) { - this.setIssue(path, "Invalid frontmatter."); - return null; - } - - const name = asString(frontmatter.name); - const model = asString(frontmatter.model) ?? this.settings.defaultModel; - if (!name || !model) { - this.setIssue(path, "Agent requires string field `name` and a valid model or default model setting."); - return null; - } - - // Legacy migration for flat agents: pull rules from frontmatter - // allowed_tools/blocked_tools when present. The sidecar - // .permissions.json (read in loadFile) will override these if it - // exists and parses cleanly. Saving the agent rewrites rules to the - // sidecar and stops emitting the legacy frontmatter fields. - const legacyAllow = asStringArray(frontmatter.allowed_tools); - const legacyDeny = asStringArray(frontmatter.blocked_tools); - if ((legacyAllow.length > 0 || legacyDeny.length > 0) && !this.warnedLegacyPerms.has(name)) { - this.warnedLegacyPerms.add(name); - console.warn( - `Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in its frontmatter. ` + - `Permission rules now live in .permissions.json beside the .md. Open Edit and Save to migrate.`, - ); - } - - return { - filePath: path, - name, - description: asString(frontmatter.description), - model, - adapter: asString(frontmatter.adapter) ?? "claude-code", - permissionMode: asString(frontmatter.permission_mode) ?? "bypassPermissions", - effort: asString(frontmatter.effort), - maxRetries: asNumber(frontmatter.max_retries, 1), - skills: asStringArray(frontmatter.skills), - mcpServers: asStringArray(frontmatter.mcp_servers), - cwd: asString(frontmatter.cwd), - enabled: asBoolean(frontmatter.enabled, true), - timeout: asNumber(frontmatter.timeout, 300), - approvalRequired: asStringArray(frontmatter.approval_required), - memory: asBoolean(frontmatter.memory, false), - memoryMaxEntries: asNumber(frontmatter.memory_max_entries, 100), - memoryTokenBudget: resolveMemoryTokenBudget(frontmatter), - reflection: parseReflectionConfig(frontmatter), - autoCompactThreshold: asNumber(frontmatter.auto_compact_threshold, 85), - tags: asStringArray(frontmatter.tags), - avatar: asString(frontmatter.avatar) ?? "", - body, - contextBody: "", - skillsBody: "", - env: this.parseEnvMap(frontmatter.env), - permissionRules: { allow: legacyAllow, deny: legacyDeny }, - isFolder: false, - heartbeatEnabled: false, - heartbeatSchedule: "", - heartbeatBody: "", - heartbeatNotify: true, - heartbeatChannel: "", - heartbeatChannelTarget: "", - }; - } - - private parseSkill(path: string, content: string): SkillConfig | null { - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - const name = asString(frontmatter.name); - if (!name) { - this.setIssue(path, "Skill requires string field `name`."); - return null; - } - - return { - filePath: path, - name, - description: asString(frontmatter.description), - tags: asStringArray(frontmatter.tags), - body, - toolsBody: "", - referencesBody: "", - examplesBody: "", - isFolder: false, - }; - } - - private parseTask(path: string, content: string): TaskConfig | null { - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - const taskId = asString(frontmatter.task_id); - const agent = asString(frontmatter.agent); - const type = asString(frontmatter.type) as TaskConfig["type"] | undefined; - if (!taskId || !agent || !type) { - this.setIssue(path, "Task requires `task_id`, `agent`, and `type`."); - return null; - } - if (type === "recurring" && !asString(frontmatter.schedule)) { - this.setIssue(path, "Recurring task requires `schedule`."); - return null; - } - if (type === "once" && !asString(frontmatter.run_at)) { - this.setIssue(path, "One-time task requires `run_at`."); - return null; - } - - const rawPriority = asString(frontmatter.priority); - const validPriorities = ["low", "medium", "high", "critical"]; - const priority = (rawPriority && validPriorities.includes(rawPriority) ? rawPriority : "medium") as TaskConfig["priority"]; - - return { - filePath: path, - taskId, - agent, - schedule: asString(frontmatter.schedule), - runAt: asString(frontmatter.run_at), - type, - priority, - enabled: asBoolean(frontmatter.enabled, true), - created: asString(frontmatter.created) ?? new Date().toISOString(), - lastRun: asString(frontmatter.last_run), - nextRun: asString(frontmatter.next_run), - runCount: asNumber(frontmatter.run_count, 0), - catchUp: asBoolean(frontmatter.catch_up, this.settings.catchUpMissedTasks), - effort: asString(frontmatter.effort), - model: asString(frontmatter.model), - channel: asString(frontmatter.channel), - channelTarget: asString(frontmatter.channel_target), - tags: asStringArray(frontmatter.tags), - body, - }; - } - - private parseChannelFile(path: string, content: string): ChannelConfig | null { - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - - const name = asString(frontmatter.name); - if (!name) { - this.setIssue(path, "Channel requires string field `name`."); - return null; - } - - const rawType = asString(frontmatter.type); - const validTypes: readonly string[] = ["slack", "telegram", "discord"]; - if (!rawType || !validTypes.includes(rawType)) { - this.setIssue( - path, - `Channel \`${name}\` requires \`type\` to be one of: ${validTypes.join(", ")}.`, - ); - return null; - } - const type = rawType as ChannelConfig["type"]; - - // `default_agent` is the canonical field; `agent` is accepted as a backward-compat alias. - const defaultAgent = asString(frontmatter.default_agent) ?? asString(frontmatter.agent); - if (!defaultAgent) { - this.setIssue(path, `Channel \`${name}\` requires \`default_agent\` (or \`agent\`).`); - return null; - } - - const allowedAgents = asStringArray(frontmatter.allowed_agents); - - const credentialRef = asString(frontmatter.credential_ref); - if (!credentialRef) { - this.setIssue( - path, - `Channel \`${name}\` requires \`credential_ref\` pointing at a configured credential.`, - ); - return null; - } - - const transport = isRecord(frontmatter.transport) ? frontmatter.transport : {}; - - return { - filePath: path, - name, - type, - defaultAgent, - allowedAgents, - enabled: asBoolean(frontmatter.enabled, true), - credentialRef, - allowedUsers: asStringArray(frontmatter.allowed_users), - perUserSessions: asBoolean(frontmatter.per_user_sessions, true), - channelContext: asString(frontmatter.channel_context) ?? "", - transport, - tags: asStringArray(frontmatter.tags), - body, - }; - } - - /** - * Parse one `_fleet/mcp/.md` registry file. The frontmatter holds the - * non-secret server definition; the body is a human description. Secrets - * (bearer/OAuth tokens, secret env values) are NEVER read here — they live in - * SecretStore and are resolved at projection time. - */ - private parseMcpServerFile(path: string, content: string): McpServer | null { - const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); - - const name = asString(frontmatter.name); - if (!name) { - this.setIssue(path, "MCP server requires string field `name`."); - return null; - } - - // `transport` is the canonical frontmatter key; `type` is accepted as an - // alias (matches the native config vocabulary). - const rawTransport = (asString(frontmatter.transport) ?? asString(frontmatter.type) ?? "").toLowerCase(); - const validTransports: readonly string[] = ["stdio", "http", "sse"]; - if (!validTransports.includes(rawTransport)) { - this.setIssue( - path, - `MCP server \`${name}\` requires \`transport\` to be one of: ${validTransports.join(", ")}.`, - ); - return null; - } - const type = rawTransport as McpServer["type"]; - - if (type === "stdio") { - if (!asString(frontmatter.command)) { - this.setIssue(path, `MCP server \`${name}\` (stdio) requires a \`command\`.`); - return null; - } - } else if (!asString(frontmatter.url)) { - this.setIssue(path, `MCP server \`${name}\` (${type}) requires a \`url\`.`); - return null; - } - - const rawAuth = (asString(frontmatter.auth) ?? "").toLowerCase(); - const auth: McpServer["auth"] = - rawAuth === "bearer" || rawAuth === "oauth" || rawAuth === "none" ? rawAuth : undefined; - - let oauth: McpServer["oauth"]; - if (isRecord(frontmatter.oauth)) { - const o = frontmatter.oauth; - oauth = { - clientId: asString(o.client_id) ?? asString(o.clientId), - resource: asString(o.resource), - scopes: asStringArray(o.scopes), - }; - } - - return { - name, - filePath: path, - type, - enabled: asBoolean(frontmatter.enabled, true), - description: asString(frontmatter.description) ?? (body.trim() || undefined), - source: asString(frontmatter.source) === "imported" ? "imported" : "manual", - command: asString(frontmatter.command), - args: asStringArray(frontmatter.args), - env: asStringMap(frontmatter.env), - envSecretKeys: asStringArray(frontmatter.env_secret_keys), - url: asString(frontmatter.url), - headers: asStringMap(frontmatter.headers), - auth, - oauth, - // Runtime-only fields — filled in by an on-demand probe, not persisted. - status: "disconnected", - scope: "user", - tools: [], - toolDetails: [], - }; - } - - private validateReferences(): void { - const skillNames = new Set(); - for (const skill of this.skills.values()) { - if (skillNames.has(skill.name)) { - this.setIssue(skill.filePath, `Duplicate skill name \`${skill.name}\`.`); - } - skillNames.add(skill.name); - } - - const agentNames = new Set(); - for (const agent of this.agents.values()) { - if (agentNames.has(agent.name)) { - this.setIssue(agent.filePath, `Duplicate agent name \`${agent.name}\`.`); - } - agentNames.add(agent.name); - for (const skillName of agent.skills) { - if (!skillNames.has(skillName)) { - this.setIssue(agent.filePath, `Agent references missing skill \`${skillName}\`.`); - } - } - } - - for (const task of this.tasks.values()) { - if (!agentNames.has(task.agent)) { - this.setIssue(task.filePath, `Task references missing agent \`${task.agent}\`.`); - } - } - - const channelNames = new Set(); - const agentsByName = new Map(); - for (const agent of this.agents.values()) { - agentsByName.set(agent.name, agent); - } - const credentials = this.channelCredentialGetter?.() ?? this.settings.channelCredentials ?? {}; - - for (const channel of this.channels.values()) { - if (channelNames.has(channel.name)) { - this.setIssue(channel.filePath, `Duplicate channel name \`${channel.name}\`.`); - } - channelNames.add(channel.name); - - const boundAgent = agentsByName.get(channel.defaultAgent); - if (!boundAgent) { - this.setIssue( - channel.filePath, - `Channel \`${channel.name}\` references missing agent \`${channel.defaultAgent}\`.`, - ); - } else if (boundAgent.approvalRequired.length > 0) { - // Channels have no approval UI; a channel turn needing approval would deadlock. - // Block the binding at load time — the channel loads but is treated as disabled. - this.setIssue( - channel.filePath, - `Channel \`${channel.name}\` cannot bind to agent \`${boundAgent.name}\` because the agent has \`approval_required\` set (${boundAgent.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`, - ); - } - - const credential = credentials[channel.credentialRef]; - if (!credential) { - this.setIssue( - channel.filePath, - `Channel \`${channel.name}\` references missing credential \`${channel.credentialRef}\`. Add it under Settings → Channel Credentials.`, - ); - } else if (credential.type !== channel.type) { - this.setIssue( - channel.filePath, - `Channel \`${channel.name}\` is type \`${channel.type}\` but credential \`${channel.credentialRef}\` is type \`${credential.type}\`.`, - ); - } - } - - const mcpNames = new Set(); - for (const server of this.mcpServers.values()) { - if (mcpNames.has(server.name)) { - this.setIssue(server.filePath ?? server.name, `Duplicate MCP server name \`${server.name}\`.`); - } - mcpNames.add(server.name); - } - } - - private parseEnvMap(value: unknown): Record { - if (!isRecord(value)) return {}; - const result: Record = {}; - for (const [k, v] of Object.entries(value)) { - if (typeof v === "string") { - result[k] = v; - } else if (typeof v === "number" || typeof v === "boolean") { - result[k] = String(v); - } - } - return result; - } - - private parseApprovals(value: unknown): RunLogData["approvals"] { - if (!Array.isArray(value)) { - return undefined; - } - - return value.flatMap((item) => { - if (!isRecord(item) || !asString(item.tool)) { - return []; - } - const tool = asString(item.tool); - if (!tool) { - return []; - } - return [ - { - tool, - command: asString(item.command), - reason: asString(item.reason), - status: (asString(item.status) as "pending" | "approved" | "rejected") ?? "pending", - resolvedAt: asString(item.resolvedAt), - note: asString(item.note), - }, - ]; - }); + return getAvailablePath(this.vault, folder, baseName); } } diff --git a/src/main.ts b/src/main.ts index ef9a5c3..6a0d260 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "fs"; -import { homedir } from "os"; +import { readdir, rm, stat } from "fs/promises"; +import { homedir, tmpdir } from "os"; import { join } from "path"; import { Notice, @@ -51,6 +52,11 @@ export default class AgentFleetPlugin extends Plugin { channelManager!: ChannelManager; secretStore!: SecretStore; + /** Successful CLI verifications, keyed by `${label}:${cliPath}` → timestamp. + * Skips re-spawning `--version` for the same binary within the TTL. */ + private cliVerifiedAt = new Map(); + private static readonly CLI_VERIFY_TTL_MS = 5 * 60_000; + private statusBarEl?: HTMLElement; private subscribedViews = new Set<{ render: () => Promise }>(); private vaultChangeTimer?: number; @@ -209,6 +215,12 @@ export default class AgentFleetPlugin extends Plugin { // Claude/Codex servers into the registry on first load. void this.importNativeMcpServers(); + // Sweep temp files a force-quit orphaned (their normal cleanup lives in + // finally blocks that never ran). Fire-and-forget — must not block load. + void this.cleanupStaleTempFiles().catch((err) => { + console.warn("Agent Fleet: stale temp-file sweep failed", err); + }); + // Periodically refresh expiring OAuth tokens (every 30 min) so the next run // projects a fresh bearer. Refreshed tokens persist to SecretStore. this.registerInterval( @@ -441,6 +453,25 @@ export default class AgentFleetPlugin extends Plugin { } private async verifyCliBinary(cliPath: string, label: string, showNotice: boolean): Promise { + // Skip re-spawning `--version` if this exact binary verified successfully + // recently — settings edits would otherwise probe on every save. + const cacheKey = `${label}:${cliPath}`; + const verifiedAt = this.cliVerifiedAt.get(cacheKey); + if (verifiedAt !== undefined && Date.now() - verifiedAt < AgentFleetPlugin.CLI_VERIFY_TTL_MS) { + if (showNotice) { + new Notice(`${label} CLI available.`); + } + return true; + } + + const installHint = + label === "Claude" + ? "install with: npm install -g @anthropic-ai/claude-code" + : "install with: npm install -g @openai/codex"; + const failureMessage = + `${label} CLI verification failed (path: ${cliPath || "not set"}). ` + + `Fix the ${label} CLI Path in settings, or ${installHint}`; + return await new Promise((resolve) => { // On macOS/Linux: spawns through login shell so env vars are available. // On Windows: spawns directly (env is inherited from the system). @@ -451,24 +482,71 @@ export default class AgentFleetPlugin extends Plugin { }); proc.on("close", (code) => { const ok = code === 0; - if (!ok) { + if (ok) { + this.cliVerifiedAt.set(cacheKey, Date.now()); + } else { console.error(`Agent Fleet: ${label} CLI verification failed`, stderr); } if (showNotice) { - new Notice(ok ? `${label} CLI available.` : `${label} CLI verification failed — check ${label} CLI Path in settings.`); + new Notice(ok ? `${label} CLI available.` : failureMessage, ok ? 5000 : 10000); } resolve(ok); }); proc.on("error", (error) => { console.error(`Agent Fleet: ${label} CLI verification error`, error); if (showNotice) { - new Notice(`${label} CLI verification failed — check ${label} CLI Path in settings.`); + new Notice(failureMessage, 10000); } resolve(false); }); }); } + /** + * Best-effort removal of per-run temp files that a force-quit left behind: + * MCP projection files under `/.claude/` — `af-mcp..json` + * and `af-mcp-..cjs` (mcpProjection.ts) plus + * `af-remember-mcp..{json,cjs}` (rememberMcpServer.ts) — older than + * 24h, and per-agent CODEX_HOME overlay dirs under the OS temp dir + * (codexPermissions.ts OVERLAY_ROOT) older than 7 days. Conservative: only + * names matching the plugin's own prefixes are touched. + */ + private async cleanupStaleTempFiles(): Promise { + const now = Date.now(); + const sweep = async (dir: string, matches: (name: string) => boolean, maxAgeMs: number) => { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; // dir missing/unreadable — nothing to sweep + } + for (const name of entries) { + if (!matches(name)) continue; + const fullPath = join(dir, name); + try { + const info = await stat(fullPath); + if (now - info.mtimeMs > maxAgeMs) { + await rm(fullPath, { recursive: true, force: true }); + } + } catch { + // best-effort — skip entries we can't stat/remove + } + } + }; + + const vaultBase = this.repository.getVaultBasePath(); + if (vaultBase) { + await sweep( + join(vaultBase, ".claude"), + (name) => /^(af-mcp|af-remember-mcp)[.-].+\.(json|cjs)$/.test(name), + 24 * 60 * 60 * 1000, + ); + } + // Overlays are keyed by agent and rebuilt on demand, so removing old ones + // is safe even if the agent still exists. + await sweep(join(tmpdir(), "agent-fleet-codex"), () => true, 7 * 24 * 60 * 60 * 1000); + } + async openPath(path: string): Promise { const file = this.app.vault.getAbstractFileByPath(normalizePath(path)); if (file instanceof TFile) { diff --git a/src/repository/conversationStore.test.ts b/src/repository/conversationStore.test.ts new file mode 100644 index 0000000..b05df0e --- /dev/null +++ b/src/repository/conversationStore.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentConfig } from "../types"; +import { ConversationStore } from "./conversationStore"; +import { FakeVault, makeApp } from "./testSupport"; + +function agent(overrides: Partial): AgentConfig { + return { + filePath: "_fleet/agents/bot.md", + name: "bot", + model: "sonnet", + adapter: "claude-code", + permissionMode: "bypassPermissions", + maxRetries: 1, + skills: [], + mcpServers: [], + enabled: true, + timeout: 300, + approvalRequired: [], + memory: false, + memoryMaxEntries: 100, + memoryTokenBudget: 2000, + reflection: { + enabled: false, + schedule: "0 3 * * *", + recurrenceThreshold: 3, + proposeSkills: false, + model: undefined, + }, + autoCompactThreshold: 85, + tags: [], + avatar: "", + body: "", + contextBody: "", + skillsBody: "", + env: {}, + permissionRules: { allow: [], deny: [] }, + isFolder: false, + heartbeatEnabled: false, + heartbeatSchedule: "", + heartbeatBody: "", + heartbeatNotify: true, + heartbeatChannel: "", + heartbeatChannelTarget: "", + ...overrides, + } as AgentConfig; +} + +describe("ConversationStore", () => { + let vault: FakeVault; + let store: ConversationStore; + + beforeEach(() => { + vault = new FakeVault(); + store = new ConversationStore( + makeApp(vault), + (name) => `_fleet/memory/${name}.md`, + ); + }); + + it("flat agents park conversations beside the memory dir; folder agents nest in their folder", async () => { + await store.createConversation(agent({}), "chat-1", "First"); + expect(vault.files.has("_fleet/memory/bot-conversations/chat-1.json")).toBe(true); + + const folderAgent = agent({ isFolder: true, filePath: "_fleet/agents/bot/agent.md" }); + await store.createConversation(folderAgent, "chat-1", "First"); + expect(vault.files.has("_fleet/agents/bot/conversations/chat-1.json")).toBe(true); + }); + + it("createConversation is idempotent — an existing file is left alone", async () => { + const a = agent({}); + await store.createConversation(a, "chat-1", "Original"); + const before = vault.contents.get("_fleet/memory/bot-conversations/chat-1.json"); + await store.createConversation(a, "chat-1", "Renamed attempt"); + expect(vault.contents.get("_fleet/memory/bot-conversations/chat-1.json")).toBe(before); + }); + + it("lists conversations sorted by lastActive desc, skipping unreadable files", async () => { + const a = agent({}); + const dir = "_fleet/memory/bot-conversations"; + vault.addFile(`${dir}/old.json`, JSON.stringify({ name: "Old", lastActive: "2026-01-01T00:00:00Z", messages: [1] })); + vault.addFile(`${dir}/new.json`, JSON.stringify({ name: "New", lastActive: "2026-07-01T00:00:00Z", messages: [] })); + vault.addFile(`${dir}/broken.json`, "{not json"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const list = await store.listConversations(a); + + expect(list.map((c) => c.id)).toEqual(["new", "old"]); + expect(list[0]?.messageCount).toBe(0); + expect(list[1]?.messageCount).toBe(1); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("renameConversation rewrites the name field", async () => { + const a = agent({}); + await store.createConversation(a, "chat-1", "Before"); + await store.renameConversation(a, "chat-1", "After"); + const state = JSON.parse(vault.contents.get("_fleet/memory/bot-conversations/chat-1.json") ?? "{}") as { + name?: string; + }; + expect(state.name).toBe("After"); + }); + + it("deleteConversation trashes the file and the parent folder once empty", async () => { + const a = agent({}); + await store.createConversation(a, "chat-1", "Only"); + await store.deleteConversation(a, "chat-1"); + expect(vault.files.has("_fleet/memory/bot-conversations/chat-1.json")).toBe(false); + expect(vault.folders.has("_fleet/memory/bot-conversations")).toBe(false); + }); +}); diff --git a/src/repository/conversationStore.ts b/src/repository/conversationStore.ts new file mode 100644 index 0000000..c887148 --- /dev/null +++ b/src/repository/conversationStore.ts @@ -0,0 +1,146 @@ +import { TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import { slugify } from "../utils/markdown"; +import type { AgentConfig, ConversationMeta } from "../types"; +import { ensureFolder } from "./shared"; + +/** + * Conversation registry (in-app multi-conversation): per-conversation JSON + * state files under the agent's `conversations/` folder. Extracted verbatim + * from FleetRepository. + */ +export class ConversationStore { + private readonly vault: Vault; + + constructor( + private readonly app: App, + /** Facade's legacy flat memory path — flat agents park their + * conversations beside it (`/-conversations`). */ + private readonly getLegacyMemoryPath: (agentName: string) => string, + ) { + this.vault = app.vault; + } + + /** Folder that holds per-conversation JSON files for an agent. Folder + * agents nest under their own folder; flat agents share the memory dir. */ + private getConversationsDir(agent: AgentConfig): string { + if (agent.isFolder) { + const folderPath = agent.filePath.replace(/\/agent\.md$/, ""); + return normalizePath(`${folderPath}/conversations`); + } + const memoryDir = this.getLegacyMemoryPath(agent.name).replace(/\/[^/]+$/, ""); + return normalizePath(`${memoryDir}/${agent.name}-conversations`); + } + + /** Path to a conversation's state file. The id is slugified for safety + * on disk; callers should treat the original id as opaque. */ + private getConversationPath(agent: AgentConfig, conversationId: string): string { + const dir = this.getConversationsDir(agent); + const safeId = slugify(conversationId) || "conversation"; + return normalizePath(`${dir}/${safeId}.json`); + } + + /** List all conversations for an agent, sorted by lastActive desc. + * Scans the conversations/ folder; pre-feature chat.json files (if any) + * are intentionally NOT surfaced here — see the "no migration" decision + * in the conversation-feature design notes. */ + async listConversations(agent: AgentConfig): Promise { + const out: ConversationMeta[] = []; + const dir = this.getConversationsDir(agent); + const folder = this.vault.getAbstractFileByPath(dir); + if (folder instanceof TFolder) { + for (const child of folder.children) { + if (!(child instanceof TFile) || child.extension !== "json") continue; + const id = child.basename; + const meta = await this.readConversationMeta(child, id); + if (meta) out.push(meta); + } + } + out.sort((a, b) => b.lastActive.localeCompare(a.lastActive)); + return out; + } + + private async readConversationMeta( + file: TFile, + id: string, + ): Promise { + try { + const content = await this.vault.cachedRead(file); + const state = JSON.parse(content) as { + name?: string; + lastActive?: string; + messages?: unknown[]; + }; + return { + id, + name: state.name?.trim() || "Untitled", + lastActive: state.lastActive ?? new Date(file.stat.mtime).toISOString(), + messageCount: Array.isArray(state.messages) ? state.messages.length : 0, + }; + } catch (err) { + console.warn(`Agent Fleet: skipping unreadable conversation file ${file.path}`, err); + return null; + } + } + + /** Create an empty conversation file with a given id + name. Idempotent — + * if the file already exists, leaves it alone (callers can just switch + * into it). */ + async createConversation(agent: AgentConfig, id: string, name: string): Promise { + const path = this.getConversationPath(agent, id); + const existing = this.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) return; + const dir = path.replace(/\/[^/]+$/, ""); + await ensureFolder(this.vault, dir); + const now = new Date().toISOString(); + const state = { + sessionId: null, + messages: [], + lastActive: now, + createdAt: now, + name: name.trim(), + }; + await this.vault.create(path, JSON.stringify(state, null, 2)); + } + + /** Rename a conversation by writing a new `name` field into its state file. */ + async renameConversation(agent: AgentConfig, conversationId: string, name: string): Promise { + const path = this.getConversationPath(agent, conversationId); + const file = this.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return; + const content = await this.vault.cachedRead(file); + let state: Record; + try { + state = JSON.parse(content) as Record; + } catch (err) { + console.warn(`Agent Fleet: cannot rename conversation — invalid JSON in ${path}`, err); + return; + } + state.name = name.trim(); + await this.vault.modify(file, JSON.stringify(state, null, 2)); + } + + /** Delete a conversation: the JSON file, its threads sidecar, and the + * parent `conversations/` folder if it just emptied out. The chat view + * auto-creates a fresh conversation if the user deletes their last one, + * so this never strands the panel in a no-conversations state. */ + async deleteConversation(agent: AgentConfig, conversationId: string): Promise { + const path = this.getConversationPath(agent, conversationId); + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + await this.app.fileManager.trashFile(file); + } + + const threadsDir = path.replace(/\.json$/, ".threads"); + const threadsFolder = this.vault.getAbstractFileByPath(threadsDir); + if (threadsFolder instanceof TFolder) { + await this.app.fileManager.trashFile(threadsFolder); + } + + const dir = this.getConversationsDir(agent); + const folder = this.vault.getAbstractFileByPath(dir); + if (folder instanceof TFolder && folder.children.length === 0) { + await this.app.fileManager.trashFile(folder); + } + } +} diff --git a/src/repository/entityMutations.ts b/src/repository/entityMutations.ts new file mode 100644 index 0000000..a458fa6 --- /dev/null +++ b/src/repository/entityMutations.ts @@ -0,0 +1,716 @@ +import { TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import type { FLEET_SUBFOLDERS } from "../constants"; +import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown"; +import { collectMarkdownChildren, createFileIfMissing, ensureFolder, getAvailablePath, trashPath } from "./shared"; +import { sidecarPermissionsPath } from "./entityParsers"; +import type { EntityStore } from "./entityStore"; +import type { TaskConfig } from "../types"; + +// ═══════════════════════════════════════════════════════ +// Update/create option shapes — structurally identical to the inline object +// types the facade methods have always taken, so callers are unaffected. +// ═══════════════════════════════════════════════════════ + +export interface CreateAgentFolderOptions { + name: string; + description: string; + avatar: string; + tags: string[]; + systemPrompt: string; + model: string; + adapter: string; + cwd: string; + timeout: number; + permissionMode: string; + effort?: string; + approvalRequired: string[]; + memory: boolean; + memoryMaxEntries: number; + skills: string[]; + mcpServers?: string[]; + skillsBody: string; + contextBody: string; + enabled?: boolean; + permissionRules?: { allow: string[]; deny: string[] }; + autoCompactThreshold?: number; + wikiReferences?: string[]; +} + +export interface CreateSkillFolderOptions { + name: string; + description: string; + tags: string[]; + body: string; + toolsBody: string; + referencesBody: string; + examplesBody: string; +} + +export interface AgentUpdates { + description?: string; + avatar?: string; + tags?: string[]; + systemPrompt?: string; + model?: string; + adapter?: string; + cwd?: string; + timeout?: number; + permissionMode?: string; + effort?: string; + permissionRules?: { allow: string[]; deny: string[] }; + approvalRequired?: string[]; + memory?: boolean; + memoryTokenBudget?: number; + reflectionEnabled?: boolean; + reflectionSchedule?: string; + reflectionProposeSkills?: boolean; + skills?: string[]; + mcpServers?: string[]; + skillsBody?: string; + contextBody?: string; + enabled?: boolean; + /** 0-100; percent of context window at which auto-compact fires. */ + autoCompactThreshold?: number; + /** Names of Wiki Keeper agents this agent can read from. */ + wikiReferences?: string[]; +} + +export interface TaskUpdates { + agent?: string; + type?: string; + schedule?: string; + runAt?: string; + enabled?: boolean; + priority?: string; + catch_up?: boolean; + effort?: string; + model?: string; + channel?: string; + channelTarget?: string; + tags?: string[]; + body?: string; +} + +export interface SkillUpdates { + description?: string; + tags?: string[]; + body?: string; + toolsBody?: string; + referencesBody?: string; + examplesBody?: string; +} + +export interface ChannelUpdates { + default_agent?: string; + allowed_agents?: string[]; + enabled?: boolean; + credential_ref?: string; + allowed_users?: string[]; + per_user_sessions?: boolean; + channel_context?: string; + tags?: string[]; + body?: string; + type?: string; + transport?: Record; +} + +export interface HeartbeatUpdates { + enabled?: boolean; + schedule?: string; + notify?: boolean; + channel?: string; + channelTarget?: string; + body?: string; +} + +/** + * Entity create/update/delete: persists changes to the vault files, then + * drives the EntityStore (loadFile / clearStoredFile / validateReferences) so + * the in-memory maps and reference issues are fresh without waiting for the + * debounced vault-event reload. Extracted verbatim from the FleetRepository + * facade; memory paths are injected because deleting an agent also trashes + * its MemoryStore-owned files. + */ +export class EntityMutations { + private readonly vault: Vault; + + constructor( + private readonly app: App, + private readonly deps: { + store: EntityStore; + getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string; + getMemoryPath: (agentName: string) => string; + getMemoryDir: (agentName: string) => string; + }, + ) { + this.vault = app.vault; + } + + private get store(): EntityStore { + return this.deps.store; + } + + async updateTaskRunMetadata(task: TaskConfig, updates: Partial>): Promise { + const file = this.vault.getAbstractFileByPath(task.filePath); + if (!(file instanceof TFile)) { + return; + } + + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + const nextFrontmatter = { + ...frontmatter, + last_run: updates.lastRun ?? task.lastRun, + next_run: updates.nextRun ?? task.nextRun, + run_count: updates.runCount ?? task.runCount, + }; + + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(nextFrontmatter, body)); + await this.store.loadFile(file); + } + + async createAgentTemplate(name: string): Promise { + const path = await getAvailablePath(this.vault, this.deps.getSubfolder("agents"), slugify(name)); + const content = `---\nname: ${slugify(name)}\ndescription: \nenabled: true\nskills: []\ntags: []\n---\n\nAgent instructions go here.\n`; + const file = await this.vault.create(path, content); + // Register the new entity in-memory and revalidate references immediately + // (a new agent can resolve a task's missing-agent issue) instead of waiting + // for the debounced vault-event reload. + await this.store.loadFile(file); + return file; + } + + async createAgentFolder(opts: CreateAgentFolderOptions): Promise { + const slug = slugify(opts.name); + const folderPath = normalizePath(`${this.deps.getSubfolder("agents")}/${slug}`); + await ensureFolder(this.vault, folderPath); + + // agent.md + const agentFm: Record = { + name: opts.name, + description: opts.description || undefined, + avatar: opts.avatar || undefined, + enabled: opts.enabled ?? true, + tags: opts.tags, + skills: opts.skills, + mcp_servers: opts.mcpServers?.length ? opts.mcpServers : undefined, + }; + if (opts.model && opts.model !== "default") { + agentFm.model = opts.model; + } + const agentPath = normalizePath(`${folderPath}/agent.md`); + await this.vault.create(agentPath, stringifyMarkdownWithFrontmatter(agentFm, opts.systemPrompt || "")); + + // config.md + const configFm: Record = { + model: opts.model || "default", + adapter: opts.adapter || "claude-code", + timeout: opts.timeout, + max_retries: 1, + cwd: opts.cwd || "", + permission_mode: opts.permissionMode || "bypassPermissions", + effort: opts.effort || undefined, + approval_required: opts.approvalRequired, + memory: opts.memory, + memory_max_entries: opts.memoryMaxEntries, + }; + if (typeof opts.autoCompactThreshold === "number") { + configFm.auto_compact_threshold = opts.autoCompactThreshold; + } + if (opts.wikiReferences && opts.wikiReferences.length > 0) { + configFm.wiki_references = opts.wikiReferences.map((agent) => ({ agent })); + } + const configPath = normalizePath(`${folderPath}/config.md`); + await this.vault.create(configPath, stringifyMarkdownWithFrontmatter(configFm, "")); + + // SKILLS.md + const skillsPath = normalizePath(`${folderPath}/SKILLS.md`); + await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, opts.skillsBody || "")); + + // CONTEXT.md + const contextPath = normalizePath(`${folderPath}/CONTEXT.md`); + await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, opts.contextBody || "")); + + // permissions.json (only if rules are non-empty) + const rules = opts.permissionRules; + if (rules && (rules.allow.length > 0 || rules.deny.length > 0)) { + const permPath = normalizePath(`${folderPath}/permissions.json`); + await this.vault.create(permPath, JSON.stringify(rules, null, 2) + "\n"); + } + + // Register the new folder agent in-memory + revalidate references now. + await this.store.loadFile(agentPath); + return agentPath; + } + + async createSkillTemplate(name: string): Promise { + const path = await getAvailablePath(this.vault, this.deps.getSubfolder("skills"), slugify(name)); + const content = `---\nname: ${slugify(name)}\ndescription: \ntags: []\n---\n\nSkill instructions go here.\n`; + const file = await this.vault.create(path, content); + // Register in-memory + revalidate (a new skill can resolve an agent's + // missing-skill issue) without waiting for the debounced vault reload. + await this.store.loadFile(file); + return file; + } + + async createSkillFolder(opts: CreateSkillFolderOptions): Promise { + const folderPath = normalizePath(`${this.deps.getSubfolder("skills")}/${slugify(opts.name)}`); + await ensureFolder(this.vault, folderPath); + + const skillFm = { + name: opts.name, + description: opts.description || undefined, + tags: opts.tags.length > 0 ? opts.tags : undefined, + }; + const skillPath = normalizePath(`${folderPath}/skill.md`); + await createFileIfMissing(this.vault, skillPath, stringifyMarkdownWithFrontmatter(skillFm, opts.body || "Skill instructions go here.")); + + if (opts.toolsBody) { + const toolsPath = normalizePath(`${folderPath}/tools.md`); + await createFileIfMissing(this.vault, toolsPath, `# Tools\n\n${opts.toolsBody}`); + } + + if (opts.referencesBody) { + const refsPath = normalizePath(`${folderPath}/references.md`); + await createFileIfMissing(this.vault, refsPath, `# References\n\n${opts.referencesBody}`); + } + + if (opts.examplesBody) { + const examplesPath = normalizePath(`${folderPath}/examples.md`); + await createFileIfMissing(this.vault, examplesPath, `# Examples\n\n${opts.examplesBody}`); + } + + // Register the new folder skill in-memory + revalidate references now. + await this.store.loadFile(skillPath); + } + + async updateAgent(agentName: string, updates: AgentUpdates): Promise { + const agent = this.store.getAgentByName(agentName); + if (!agent) return; + + if (agent.isFolder) { + const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, "")); + + // Update agent.md + const agentFile = this.vault.getAbstractFileByPath(agent.filePath); + if (agentFile instanceof TFile) { + const content = await this.vault.cachedRead(agentFile); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.description !== undefined) frontmatter.description = updates.description || undefined; + if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined; + if (updates.tags !== undefined) frontmatter.tags = updates.tags; + if (updates.skills !== undefined) frontmatter.skills = updates.skills; + if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined; + if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; + if (updates.model !== undefined && updates.model !== "default") frontmatter.model = updates.model; + const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body; + await this.vault.modify(agentFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + } + + // Update config.md + const configPath = normalizePath(`${folderPath}/config.md`); + const configFile = this.vault.getAbstractFileByPath(configPath); + if (configFile instanceof TFile) { + const content = await this.vault.cachedRead(configFile); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.model !== undefined) frontmatter.model = updates.model; + if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter; + if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout; + if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd; + if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode; + if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined; + if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired; + if (updates.memory !== undefined) frontmatter.memory = updates.memory; + if (updates.memoryTokenBudget !== undefined) frontmatter.memory_token_budget = updates.memoryTokenBudget; + if (updates.reflectionEnabled !== undefined) frontmatter.reflection_enabled = updates.reflectionEnabled; + if (updates.reflectionSchedule !== undefined) frontmatter.reflection_schedule = updates.reflectionSchedule; + if (updates.reflectionProposeSkills !== undefined) frontmatter.reflection_propose_skills = updates.reflectionProposeSkills; + if (updates.autoCompactThreshold !== undefined) { + frontmatter.auto_compact_threshold = updates.autoCompactThreshold; + } + if (updates.wikiReferences !== undefined) { + frontmatter.wiki_references = updates.wikiReferences.length > 0 + ? updates.wikiReferences.map((agent) => ({ agent })) + : undefined; + } + // Strip dead legacy permission fields whenever we touch config.md — + // permissions.json is now the canonical surface for allow/deny. + delete frontmatter.allowed_tools; + delete frontmatter.blocked_tools; + await this.vault.modify(configFile, stringifyMarkdownWithFrontmatter(frontmatter, body)); + } + + // Update SKILLS.md + if (updates.skillsBody !== undefined) { + const skillsPath = normalizePath(`${folderPath}/SKILLS.md`); + const skillsFile = this.vault.getAbstractFileByPath(skillsPath); + if (skillsFile instanceof TFile) { + await this.vault.modify(skillsFile, stringifyMarkdownWithFrontmatter({}, updates.skillsBody)); + } else { + await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, updates.skillsBody)); + } + } + + // Update CONTEXT.md + if (updates.contextBody !== undefined) { + const contextPath = normalizePath(`${folderPath}/CONTEXT.md`); + const contextFile = this.vault.getAbstractFileByPath(contextPath); + if (contextFile instanceof TFile) { + await this.vault.modify(contextFile, stringifyMarkdownWithFrontmatter({}, updates.contextBody)); + } else { + await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, updates.contextBody)); + } + } + + // Update permissions.json + if (updates.permissionRules !== undefined) { + const permPath = normalizePath(`${folderPath}/permissions.json`); + const permFile = this.vault.getAbstractFileByPath(permPath); + const rules = updates.permissionRules; + if (rules.allow.length > 0 || rules.deny.length > 0) { + const content = JSON.stringify(rules, null, 2) + "\n"; + if (permFile instanceof TFile) { + await this.vault.modify(permFile, content); + } else { + await this.vault.create(permPath, content); + } + } else if (permFile instanceof TFile) { + // Remove permissions.json if both lists are empty + await this.app.fileManager.trashFile(permFile); + } + } + } else { + // Legacy single-file agent + const file = this.vault.getAbstractFileByPath(agent.filePath); + if (!(file instanceof TFile)) return; + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.description !== undefined) frontmatter.description = updates.description || undefined; + if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined; + if (updates.tags !== undefined) frontmatter.tags = updates.tags; + if (updates.skills !== undefined) frontmatter.skills = updates.skills; + if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined; + if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; + if (updates.model !== undefined) frontmatter.model = updates.model; + if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter; + if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout; + if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd; + if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode; + if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined; + if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired; + if (updates.memory !== undefined) frontmatter.memory = updates.memory; + if (updates.autoCompactThreshold !== undefined) { + frontmatter.auto_compact_threshold = updates.autoCompactThreshold; + } + if (updates.wikiReferences !== undefined) { + frontmatter.wiki_references = updates.wikiReferences.length > 0 + ? updates.wikiReferences.map((agent) => ({ agent })) + : undefined; + } + // Strip dead legacy permission fields whenever we touch the file — + // permissions.json sidecar is now the canonical surface. + delete frontmatter.allowed_tools; + delete frontmatter.blocked_tools; + const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body; + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + + // Sidecar permissions.json for flat agents — parity with folder agents. + if (updates.permissionRules !== undefined) { + const sidecarPath = sidecarPermissionsPath(agent.filePath); + const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath); + const rules = updates.permissionRules; + if (rules.allow.length > 0 || rules.deny.length > 0) { + const content = JSON.stringify(rules, null, 2) + "\n"; + if (sidecarFile instanceof TFile) { + await this.vault.modify(sidecarFile, content); + } else { + await this.vault.create(sidecarPath, content); + } + } else if (sidecarFile instanceof TFile) { + await this.app.fileManager.trashFile(sidecarFile); + } + } + } + + // Reload the edited entity into the in-memory maps and revalidate — the + // skills list may now reference a missing skill (or resolve one). + await this.store.loadFile(agent.filePath); + } + + async updateTask(taskId: string, updates: TaskUpdates): Promise { + const task = this.store.getTaskById(taskId); + if (!task) return; + + const file = this.vault.getAbstractFileByPath(task.filePath); + if (!(file instanceof TFile)) return; + + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.agent !== undefined) frontmatter.agent = updates.agent; + if (updates.type !== undefined) frontmatter.type = updates.type; + if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined; + if (updates.runAt !== undefined) frontmatter.run_at = updates.runAt || undefined; + if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; + if (updates.priority !== undefined) frontmatter.priority = updates.priority; + if (updates.catch_up !== undefined) frontmatter.catch_up = updates.catch_up; + if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined; + if (updates.model !== undefined) frontmatter.model = updates.model || undefined; + if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined; + if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined; + if (updates.tags !== undefined) frontmatter.tags = updates.tags; + const newBody = updates.body !== undefined ? updates.body : body; + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + + // Reload + revalidate — the task may have been retargeted to a different + // (possibly missing) agent. + await this.store.loadFile(file); + } + + async updateSkill(skillName: string, updates: SkillUpdates): Promise { + const skill = this.store.getSkillByName(skillName); + if (!skill) return; + + if (skill.isFolder) { + const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, "")); + + // Update skill.md + const skillFile = this.vault.getAbstractFileByPath(skill.filePath); + if (skillFile instanceof TFile) { + const content = await this.vault.cachedRead(skillFile); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.description !== undefined) frontmatter.description = updates.description || undefined; + if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined; + const newBody = updates.body !== undefined ? updates.body : body; + await this.vault.modify(skillFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + } + + // Update tools.md + if (updates.toolsBody !== undefined) { + const toolsPath = normalizePath(`${folderPath}/tools.md`); + const toolsFile = this.vault.getAbstractFileByPath(toolsPath); + if (updates.toolsBody) { + if (toolsFile instanceof TFile) { + await this.vault.modify(toolsFile, `# Tools\n\n${updates.toolsBody}`); + } else { + await this.vault.create(toolsPath, `# Tools\n\n${updates.toolsBody}`); + } + } + } + + // Update references.md + if (updates.referencesBody !== undefined) { + const refsPath = normalizePath(`${folderPath}/references.md`); + const refsFile = this.vault.getAbstractFileByPath(refsPath); + if (updates.referencesBody) { + if (refsFile instanceof TFile) { + await this.vault.modify(refsFile, `# References\n\n${updates.referencesBody}`); + } else { + await this.vault.create(refsPath, `# References\n\n${updates.referencesBody}`); + } + } + } + + // Update examples.md + if (updates.examplesBody !== undefined) { + const examplesPath = normalizePath(`${folderPath}/examples.md`); + const examplesFile = this.vault.getAbstractFileByPath(examplesPath); + if (updates.examplesBody) { + if (examplesFile instanceof TFile) { + await this.vault.modify(examplesFile, `# Examples\n\n${updates.examplesBody}`); + } else { + await this.vault.create(examplesPath, `# Examples\n\n${updates.examplesBody}`); + } + } + } + } else { + // Legacy single-file skill + const file = this.vault.getAbstractFileByPath(skill.filePath); + if (!(file instanceof TFile)) return; + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.description !== undefined) frontmatter.description = updates.description || undefined; + if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined; + const newBody = updates.body !== undefined ? updates.body : body; + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + } + + // Reload + revalidate (skill names can't change here, but keep the + // in-memory entity fresh and the validation pass current). + await this.store.loadFile(skill.filePath); + } + + async deleteSkill(skillName: string): Promise { + const skill = this.store.getSkillByName(skillName); + if (!skill) return; + + if (skill.isFolder) { + const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, "")); + const folder = this.vault.getAbstractFileByPath(folderPath); + if (folder instanceof TFolder) { + await this.app.fileManager.trashFile(folder); + } + this.store.clearIssuesUnder(folderPath); + } else { + await trashPath(this.app, skill.filePath); + } + + // Drop the entity from the in-memory maps and revalidate immediately so + // agents referencing the deleted skill are flagged without a full reload. + this.store.clearStoredFile(skill.filePath); + this.store.validateReferences(); + } + + async deleteTask(taskId: string): Promise { + const task = this.store.getTaskById(taskId); + if (!task) return; + await trashPath(this.app, task.filePath); + this.store.clearStoredFile(task.filePath); + this.store.validateReferences(); + } + + async updateChannel(channelName: string, updates: ChannelUpdates): Promise { + const channel = this.store.getChannelByName(channelName); + if (!channel) return; + + const file = this.vault.getAbstractFileByPath(channel.filePath); + if (!(file instanceof TFile)) return; + + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.default_agent !== undefined) { + frontmatter.default_agent = updates.default_agent; + // Remove legacy `agent` field to avoid confusion + delete frontmatter.agent; + } + if (updates.allowed_agents !== undefined) frontmatter.allowed_agents = updates.allowed_agents; + if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; + if (updates.credential_ref !== undefined) frontmatter.credential_ref = updates.credential_ref; + if (updates.allowed_users !== undefined) frontmatter.allowed_users = updates.allowed_users; + if (updates.per_user_sessions !== undefined) frontmatter.per_user_sessions = updates.per_user_sessions; + if (updates.channel_context !== undefined) frontmatter.channel_context = updates.channel_context || undefined; + if (updates.tags !== undefined) frontmatter.tags = updates.tags; + if (updates.type !== undefined) frontmatter.type = updates.type; + if (updates.transport !== undefined) frontmatter.transport = updates.transport; + const newBody = updates.body !== undefined ? updates.body : body; + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + + // Reload + revalidate — default_agent / credential_ref may now dangle. + await this.store.loadFile(file); + } + + async deleteChannel(channelName: string): Promise { + const channel = this.store.getChannelByName(channelName); + if (!channel) return; + await trashPath(this.app, channel.filePath); + this.store.clearStoredFile(channel.filePath); + this.store.validateReferences(); + // Also trash the channel's sessions folder if it exists + const sessionsFolder = normalizePath( + `${this.deps.getSubfolder("channels")}/${slugify(channelName)}/sessions`, + ); + const folder = this.vault.getAbstractFileByPath(sessionsFolder); + if (folder instanceof TFolder) { + await this.app.fileManager.trashFile(folder); + } + } + + async updateHeartbeat(agentName: string, updates: HeartbeatUpdates): Promise { + const agent = this.store.getAgentByName(agentName); + if (!agent || !agent.isFolder) return; + + const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, "")); + const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`); + const file = this.vault.getAbstractFileByPath(heartbeatPath); + + if (file instanceof TFile) { + // Update existing file + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled; + if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined; + if (updates.notify !== undefined) frontmatter.notify = updates.notify; + if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined; + if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined; + const newBody = updates.body !== undefined ? updates.body : body; + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody)); + } else { + // Create new HEARTBEAT.md + const frontmatter: Record = { + enabled: updates.enabled ?? false, + }; + if (updates.schedule) frontmatter.schedule = updates.schedule; + if (updates.notify !== undefined) frontmatter.notify = updates.notify; + if (updates.channel) frontmatter.channel = updates.channel; + if (updates.channelTarget) frontmatter.channel_target = updates.channelTarget; + const body = updates.body ?? ""; + await this.vault.create( + heartbeatPath, + stringifyMarkdownWithFrontmatter(frontmatter, body), + ); + } + + // Reload the owning folder agent so its heartbeat fields are fresh + // in-memory (reference-neutral, but keeps the snapshot consistent). + await this.store.loadFile(heartbeatPath); + } + + async deleteAgent(agentName: string, deleteTasks: boolean): Promise<{ trashedFiles: string[] }> { + const trashedFiles: string[] = []; + const agent = this.store.getAgentByName(agentName); + if (!agent) return { trashedFiles }; + + if (agent.isFolder) { + // Trash the entire agent folder + const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, "")); + const folder = this.vault.getAbstractFileByPath(folderPath); + if (folder instanceof TFolder) { + const files: TFile[] = []; + collectMarkdownChildren(folder, files); + // Trash all files inside the folder first, then the folder + for (const f of files) { + trashedFiles.push(f.path); + } + await this.app.fileManager.trashFile(folder); + } + // Issues keyed on member files (permissions.json, …) go with the folder. + this.store.clearIssuesUnder(folderPath); + } else { + // Trash the single agent definition file + await trashPath(this.app, agent.filePath); + trashedFiles.push(agent.filePath); + // Drop any issue keyed on the flat agent's permissions sidecar. + this.store.clearIssuesFor(sidecarPermissionsPath(agent.filePath)); + } + this.store.clearStoredFile(agent.filePath); + + // Trash the agent's memory: legacy flat file + v2 memory folder. + const memoryPath = this.deps.getMemoryPath(agentName); + if (this.vault.getAbstractFileByPath(memoryPath)) { + await trashPath(this.app, memoryPath); + trashedFiles.push(memoryPath); + } + const memoryDir = this.deps.getMemoryDir(agentName); + const memoryFolder = this.vault.getAbstractFileByPath(memoryDir); + if (memoryFolder instanceof TFolder) { + await this.app.fileManager.trashFile(memoryFolder); + trashedFiles.push(memoryDir); + } + + // Optionally trash associated tasks + if (deleteTasks) { + const tasks = this.store.getTasksForAgent(agentName); + for (const task of tasks) { + await trashPath(this.app, task.filePath); + trashedFiles.push(task.filePath); + this.store.clearStoredFile(task.filePath); + } + } + + // Revalidate once after all map mutations: tasks/channels referencing the + // deleted agent get flagged immediately, without a full reload. + this.store.validateReferences(); + + return { trashedFiles }; + } +} diff --git a/src/repository/entityParsers.ts b/src/repository/entityParsers.ts new file mode 100644 index 0000000..eeb9057 --- /dev/null +++ b/src/repository/entityParsers.ts @@ -0,0 +1,556 @@ +import { TFile, normalizePath } from "obsidian"; +import type { Vault } from "obsidian"; +import { + DEFAULT_MEMORY_TOKEN_BUDGET, + DEFAULT_REFLECTION_SCHEDULE, + DEFAULT_RECURRENCE_THRESHOLD, +} from "../constants"; +import { parseMarkdownWithFrontmatter } from "../utils/markdown"; +import { asBoolean, asNumber, asString, asStringArray, isRecord } from "./shared"; +import type { + AgentConfig, + ChannelConfig, + FleetSettings, + ReflectionConfig, + SkillConfig, + TaskConfig, +} from "../types"; + +export type ParsedEntity = AgentConfig | SkillConfig | TaskConfig; + +/** + * Everything the entity parsers need from their host. Extracted verbatim from + * the FleetRepository facade — the parsers used to be private methods closing + * over `this`; the context object is the same surface, made explicit: + * + * - `reportIssue` lands parse failures in the load-time validation-issue map + * (NOT the reference-issue map — see EntityStore for the two-tier split). + * - `clearIssue` drops a stale load-time issue keyed on a member file (e.g. + * permissions.json) before that file is re-evaluated on a folder reload. + * - The `warned*` sets are owned by the store so one-time console warnings + * stay one-time across reloads. + */ +export interface ParserContext { + vault: Vault; + settings: FleetSettings; + reportIssue(path: string, message: string): void; + clearIssue(path: string): void; + warnedLegacyPerms: Set; + warnedFolderAgentModelConflict: Set; +} + +let warnedLegacyBudget = false; + +/** + * Resolve an agent's working-memory token budget. Prefers the new + * `memory_token_budget`; if absent but the deprecated `memory_max_entries` is + * present, fall back to the default budget and emit a one-time console notice + * (design §13.4 — the old entry-count knob no longer maps cleanly to a budget). + */ +function resolveMemoryTokenBudget( + fm: Record, + fallback: Record = {}, +): number { + const explicit = fm.memory_token_budget ?? fallback.memory_token_budget; + if (explicit !== undefined) return asNumber(explicit, DEFAULT_MEMORY_TOKEN_BUDGET); + const legacy = fm.memory_max_entries ?? fallback.memory_max_entries; + if (legacy !== undefined && !warnedLegacyBudget) { + warnedLegacyBudget = true; + console.info( + "Agent Fleet: `memory_max_entries` is deprecated and no longer enforced; " + + "memory is now bounded by `memory_token_budget` (default " + + `${DEFAULT_MEMORY_TOKEN_BUDGET}). Set that field to tune memory size.`, + ); + } + return DEFAULT_MEMORY_TOKEN_BUDGET; +} + +/** Build a {@link ReflectionConfig} from frontmatter, with an optional fallback + * frontmatter (folder agents split fields across config.md and agent.md). */ +function parseReflectionConfig( + fm: Record, + fallback: Record = {}, +): ReflectionConfig { + const pick = (key: string): unknown => fm[key] ?? fallback[key]; + return { + enabled: asBoolean(pick("reflection_enabled"), false), + schedule: asString(pick("reflection_schedule")) ?? DEFAULT_REFLECTION_SCHEDULE, + recurrenceThreshold: asNumber(pick("reflection_recurrence_threshold"), DEFAULT_RECURRENCE_THRESHOLD), + proposeSkills: asBoolean(pick("reflection_propose_skills"), false), + model: asString(pick("reflection_model")), + }; +} + +/** For a flat agent at `/.md`, the permissions sidecar lives at + * `/.permissions.json`. Folder agents use `/permissions.json` + * (handled in loadFolderAgent). */ +export function sidecarPermissionsPath(agentMdPath: string): string { + return normalizePath(agentMdPath.replace(/\.md$/, ".permissions.json")); +} + +/** Coerce a frontmatter `env:` block into a string→string map (numbers and + * booleans stringified, other value shapes dropped). */ +function parseEnvMap(value: unknown): Record { + if (!isRecord(value)) return {}; + const result: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (typeof v === "string") { + result[k] = v; + } else if (typeof v === "number" || typeof v === "boolean") { + result[k] = String(v); + } + } + return result; +} + +/** Parse a `wiki_references:` frontmatter list into an array of references. + * Accepts either `[{ agent: "name" }, ...]` or shorthand `["name", ...]`. + * Returns undefined when the field is absent. */ +function parseWikiReferences(raw: unknown): AgentConfig["wikiReferences"] { + if (!Array.isArray(raw)) return undefined; + const refs: Array<{ agent: string }> = []; + for (const item of raw) { + if (typeof item === "string" && item.trim()) { + refs.push({ agent: item.trim() }); + } else if (item && typeof item === "object") { + const agent = (item as Record).agent; + if (typeof agent === "string" && agent.trim()) { + refs.push({ agent: agent.trim() }); + } + } + } + return refs.length > 0 ? refs : undefined; +} + +/** Parse a `wiki_keeper:` frontmatter block into a WikiKeeperConfig. + * Returns undefined when no block is present — agents without this + * are regular agents, unaffected. */ +function parseWikiKeeperConfig(raw: unknown): AgentConfig["wikiKeeper"] { + if (!raw || typeof raw !== "object") return undefined; + const r = raw as Record; + return { + scopeRoot: asString(r.scope_root) ?? "", + inboxPath: asString(r.inbox_path) ?? "_sources/inbox", + archivePath: asString(r.archive_path) ?? "_sources/archive", + failedPath: asString(r.failed_path) ?? "_sources/failed", + topicsRoot: asString(r.topics_root) ?? "_topics", + indexPath: asString(r.index_path) ?? "index.md", + logPath: asString(r.log_path) ?? "log.md", + watchedFolders: asStringArray(r.watched_folders), + excludePatterns: asStringArray(r.exclude_patterns), + watchedSince: asString(r.watched_since) ?? "", + // Default flipped to TRUE in v1.3 — Karpathy compounding requires + // substantive answers to file themselves back into the wiki. + fileSubstantiveAnswers: asBoolean(r.file_substantive_answers, true), + obsidianUrlScheme: asBoolean(r.obsidian_url_scheme, true), + maxTokensPerIngest: asNumber(r.max_tokens_per_ingest, 60000), + maxTokensPerRefresh: asNumber(r.max_tokens_per_refresh, 30000), + indexSplitThreshold: asNumber(r.index_split_threshold, 100), + dedupSimilarityThreshold: asNumber(r.dedup_similarity_threshold, 0.82), + summaryStaleDays: asNumber(r.summary_stale_days, 30), + stateFile: asString(r.state_file) ?? ".wiki-keeper-state.json", + }; +} + +// ═══════════════════════════════════════════════════════ +// Flat entity parsers (single .md files) +// ═══════════════════════════════════════════════════════ + +export function parseAgent(ctx: ParserContext, path: string, content: string): AgentConfig | null { + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + if (!isRecord(frontmatter)) { + ctx.reportIssue(path, "Invalid frontmatter."); + return null; + } + + const name = asString(frontmatter.name); + const model = asString(frontmatter.model) ?? ctx.settings.defaultModel; + if (!name || !model) { + ctx.reportIssue(path, "Agent requires string field `name` and a valid model or default model setting."); + return null; + } + + // Legacy migration for flat agents: pull rules from frontmatter + // allowed_tools/blocked_tools when present. The sidecar + // .permissions.json (read in loadFile) will override these if it + // exists and parses cleanly. Saving the agent rewrites rules to the + // sidecar and stops emitting the legacy frontmatter fields. + const legacyAllow = asStringArray(frontmatter.allowed_tools); + const legacyDeny = asStringArray(frontmatter.blocked_tools); + if ((legacyAllow.length > 0 || legacyDeny.length > 0) && !ctx.warnedLegacyPerms.has(name)) { + ctx.warnedLegacyPerms.add(name); + console.warn( + `Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in its frontmatter. ` + + `Permission rules now live in .permissions.json beside the .md. Open Edit and Save to migrate.`, + ); + } + + return { + filePath: path, + name, + description: asString(frontmatter.description), + model, + adapter: asString(frontmatter.adapter) ?? "claude-code", + permissionMode: asString(frontmatter.permission_mode) ?? "bypassPermissions", + effort: asString(frontmatter.effort), + maxRetries: asNumber(frontmatter.max_retries, 1), + skills: asStringArray(frontmatter.skills), + mcpServers: asStringArray(frontmatter.mcp_servers), + cwd: asString(frontmatter.cwd), + enabled: asBoolean(frontmatter.enabled, true), + timeout: asNumber(frontmatter.timeout, 300), + approvalRequired: asStringArray(frontmatter.approval_required), + memory: asBoolean(frontmatter.memory, false), + memoryMaxEntries: asNumber(frontmatter.memory_max_entries, 100), + memoryTokenBudget: resolveMemoryTokenBudget(frontmatter), + reflection: parseReflectionConfig(frontmatter), + autoCompactThreshold: asNumber(frontmatter.auto_compact_threshold, 85), + tags: asStringArray(frontmatter.tags), + avatar: asString(frontmatter.avatar) ?? "", + body, + contextBody: "", + skillsBody: "", + env: parseEnvMap(frontmatter.env), + permissionRules: { allow: legacyAllow, deny: legacyDeny }, + isFolder: false, + heartbeatEnabled: false, + heartbeatSchedule: "", + heartbeatBody: "", + heartbeatNotify: true, + heartbeatChannel: "", + heartbeatChannelTarget: "", + }; +} + +export function parseSkill(ctx: ParserContext, path: string, content: string): SkillConfig | null { + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + const name = asString(frontmatter.name); + if (!name) { + ctx.reportIssue(path, "Skill requires string field `name`."); + return null; + } + + return { + filePath: path, + name, + description: asString(frontmatter.description), + tags: asStringArray(frontmatter.tags), + body, + toolsBody: "", + referencesBody: "", + examplesBody: "", + isFolder: false, + }; +} + +export function parseTask(ctx: ParserContext, path: string, content: string): TaskConfig | null { + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + const taskId = asString(frontmatter.task_id); + const agent = asString(frontmatter.agent); + const type = asString(frontmatter.type) as TaskConfig["type"] | undefined; + if (!taskId || !agent || !type) { + ctx.reportIssue(path, "Task requires `task_id`, `agent`, and `type`."); + return null; + } + if (type === "recurring" && !asString(frontmatter.schedule)) { + ctx.reportIssue(path, "Recurring task requires `schedule`."); + return null; + } + if (type === "once" && !asString(frontmatter.run_at)) { + ctx.reportIssue(path, "One-time task requires `run_at`."); + return null; + } + + const rawPriority = asString(frontmatter.priority); + const validPriorities = ["low", "medium", "high", "critical"]; + const priority = (rawPriority && validPriorities.includes(rawPriority) ? rawPriority : "medium") as TaskConfig["priority"]; + + return { + filePath: path, + taskId, + agent, + schedule: asString(frontmatter.schedule), + runAt: asString(frontmatter.run_at), + type, + priority, + enabled: asBoolean(frontmatter.enabled, true), + created: asString(frontmatter.created) ?? new Date().toISOString(), + lastRun: asString(frontmatter.last_run), + nextRun: asString(frontmatter.next_run), + runCount: asNumber(frontmatter.run_count, 0), + catchUp: asBoolean(frontmatter.catch_up, ctx.settings.catchUpMissedTasks), + effort: asString(frontmatter.effort), + model: asString(frontmatter.model), + channel: asString(frontmatter.channel), + channelTarget: asString(frontmatter.channel_target), + tags: asStringArray(frontmatter.tags), + body, + }; +} + +export function parseChannelFile(ctx: ParserContext, path: string, content: string): ChannelConfig | null { + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + + const name = asString(frontmatter.name); + if (!name) { + ctx.reportIssue(path, "Channel requires string field `name`."); + return null; + } + + const rawType = asString(frontmatter.type); + const validTypes: readonly string[] = ["slack", "telegram", "discord"]; + if (!rawType || !validTypes.includes(rawType)) { + ctx.reportIssue( + path, + `Channel \`${name}\` requires \`type\` to be one of: ${validTypes.join(", ")}.`, + ); + return null; + } + const type = rawType as ChannelConfig["type"]; + + // `default_agent` is the canonical field; `agent` is accepted as a backward-compat alias. + const defaultAgent = asString(frontmatter.default_agent) ?? asString(frontmatter.agent); + if (!defaultAgent) { + ctx.reportIssue(path, `Channel \`${name}\` requires \`default_agent\` (or \`agent\`).`); + return null; + } + + const allowedAgents = asStringArray(frontmatter.allowed_agents); + + const credentialRef = asString(frontmatter.credential_ref); + if (!credentialRef) { + ctx.reportIssue( + path, + `Channel \`${name}\` requires \`credential_ref\` pointing at a configured credential.`, + ); + return null; + } + + const transport = isRecord(frontmatter.transport) ? frontmatter.transport : {}; + + return { + filePath: path, + name, + type, + defaultAgent, + allowedAgents, + enabled: asBoolean(frontmatter.enabled, true), + credentialRef, + allowedUsers: asStringArray(frontmatter.allowed_users), + perUserSessions: asBoolean(frontmatter.per_user_sessions, true), + channelContext: asString(frontmatter.channel_context) ?? "", + transport, + tags: asStringArray(frontmatter.tags), + body, + }; +} + +// ═══════════════════════════════════════════════════════ +// Folder entity loaders (agents//agent.md + member files, +// skills//skill.md + member files) +// ═══════════════════════════════════════════════════════ + +export async function loadFolderSkill( + ctx: ParserContext, + folderPath: string, + skillMdFile: TFile, +): Promise { + const skillContent = await ctx.vault.cachedRead(skillMdFile); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(skillContent); + + const name = asString(frontmatter.name); + if (!name) { + ctx.reportIssue(skillMdFile.path, "Folder skill skill.md requires string field `name`."); + return null; + } + + const readBody = async (fileName: string): Promise => { + const path = normalizePath(`${folderPath}/${fileName}`); + const file = ctx.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return ""; + const content = await ctx.vault.cachedRead(file); + const parsed = parseMarkdownWithFrontmatter>(content); + return parsed.body; + }; + + return { + filePath: skillMdFile.path, + name, + description: asString(frontmatter.description), + tags: asStringArray(frontmatter.tags), + body, + toolsBody: await readBody("tools.md"), + referencesBody: await readBody("references.md"), + examplesBody: await readBody("examples.md"), + isFolder: true, + }; +} + +export async function loadFolderAgent( + ctx: ParserContext, + folderPath: string, + agentMdFile: TFile, +): Promise { + const agentContent = await ctx.vault.cachedRead(agentMdFile); + const { frontmatter: agentFm, body: agentBody } = parseMarkdownWithFrontmatter>(agentContent); + + const name = asString(agentFm.name); + if (!name) { + ctx.reportIssue(agentMdFile.path, "Folder agent agent.md requires string field `name`."); + return null; + } + + // Read config.md + let configFm: Record = {}; + const configPath = normalizePath(`${folderPath}/config.md`); + const configFile = ctx.vault.getAbstractFileByPath(configPath); + if (configFile instanceof TFile) { + const configContent = await ctx.vault.cachedRead(configFile); + const parsed = parseMarkdownWithFrontmatter>(configContent); + configFm = parsed.frontmatter; + } + + // Read permissions.json — canonical source of truth for allow/deny rules. + // Legacy migration: if permissions.json is missing or empty BUT config.md + // carries `allowed_tools`/`blocked_tools` (the dead surface from older + // versions of this plugin), surface those values as permissionRules so + // the agent keeps working. The next call to updateAgent / updateWiki- + // KeeperAgent rewrites them into permissions.json proper. + let permissionRules: { allow: string[]; deny: string[] } = { allow: [], deny: [] }; + const permissionsPath = normalizePath(`${folderPath}/permissions.json`); + // Folder reloads bypass clearStoredFile, so drop any stale issue keyed on + // the permissions file before re-evaluating it (avoids duplicates). + ctx.clearIssue(permissionsPath); + const permissionsFile = ctx.vault.getAbstractFileByPath(permissionsPath); + if (permissionsFile instanceof TFile) { + try { + const permContent = await ctx.vault.cachedRead(permissionsFile); + const parsed = JSON.parse(permContent) as Record; + permissionRules = { + allow: asStringArray(parsed.allow), + deny: asStringArray(parsed.deny), + }; + } catch (err) { + // Fail-soft (the agent still loads) but loud: with the file unreadable + // the agent would otherwise silently run with EMPTY permission rules. + console.error( + `Agent Fleet: invalid JSON in ${permissionsPath} — agent "${name}" is running with EMPTY permission rules`, + err, + ); + ctx.reportIssue( + permissionsPath, + `Invalid JSON in permissions.json for agent \`${name}\` — permission rules are NOT applied. Fix or delete the file.`, + ); + } + } + if (permissionRules.allow.length === 0 && permissionRules.deny.length === 0) { + const legacyAllow = asStringArray(configFm.allowed_tools); + const legacyDeny = asStringArray(configFm.blocked_tools); + if (legacyAllow.length > 0 || legacyDeny.length > 0) { + permissionRules = { allow: legacyAllow, deny: legacyDeny }; + if (!ctx.warnedLegacyPerms.has(name)) { + ctx.warnedLegacyPerms.add(name); + console.warn( + `Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in config.md. ` + + `Permission rules now live in permissions.json. Open this agent in Edit and Save to migrate.`, + ); + } + } + } + + // Read SKILLS.md body + let skillsBody = ""; + const skillsPath = normalizePath(`${folderPath}/SKILLS.md`); + const skillsFile = ctx.vault.getAbstractFileByPath(skillsPath); + if (skillsFile instanceof TFile) { + const skillsContent = await ctx.vault.cachedRead(skillsFile); + const parsed = parseMarkdownWithFrontmatter>(skillsContent); + skillsBody = parsed.body; + } + + // Read CONTEXT.md body + let contextBody = ""; + const contextPath = normalizePath(`${folderPath}/CONTEXT.md`); + const contextFile = ctx.vault.getAbstractFileByPath(contextPath); + if (contextFile instanceof TFile) { + const contextContent = await ctx.vault.cachedRead(contextFile); + const parsed = parseMarkdownWithFrontmatter>(contextContent); + contextBody = parsed.body; + } + + // Read HEARTBEAT.md — autonomous periodic run instruction + let heartbeatEnabled = false; + let heartbeatSchedule = ""; + let heartbeatBody = ""; + let heartbeatNotify = true; + let heartbeatChannel = ""; + let heartbeatChannelTarget = ""; + const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`); + const heartbeatFile = ctx.vault.getAbstractFileByPath(heartbeatPath); + if (heartbeatFile instanceof TFile) { + const heartbeatContent = await ctx.vault.cachedRead(heartbeatFile); + const parsed = parseMarkdownWithFrontmatter>(heartbeatContent); + heartbeatEnabled = asBoolean(parsed.frontmatter.enabled, false); + heartbeatSchedule = asString(parsed.frontmatter.schedule) ?? ""; + heartbeatNotify = asBoolean(parsed.frontmatter.notify, true); + heartbeatChannel = asString(parsed.frontmatter.channel) ?? ""; + heartbeatChannelTarget = asString(parsed.frontmatter.channel_target) ?? ""; + heartbeatBody = parsed.body; + } + + // Model canonical home is config.md for folder agents. agent.md's `model:` + // field is deprecated but still warned-about when it conflicts with + // config.md so users can clean up their vaults. + const agentMdModel = asString(agentFm.model); + const configModel = asString(configFm.model); + if (agentMdModel && configModel && agentMdModel !== configModel) { + if (!ctx.warnedFolderAgentModelConflict.has(name)) { + ctx.warnedFolderAgentModelConflict.add(name); + console.warn( + `Agent Fleet: "${name}" has conflicting model fields — agent.md says "${agentMdModel}", config.md says "${configModel}". config.md wins. Remove agent.md's model field or sync the values to silence this warning.`, + ); + } + } + const model = configModel ?? agentMdModel ?? ctx.settings.defaultModel; + + return { + filePath: agentMdFile.path, + name, + description: asString(agentFm.description), + model, + adapter: asString(configFm.adapter) ?? "claude-code", + permissionMode: asString(configFm.permission_mode) ?? "bypassPermissions", + effort: asString(configFm.effort), + maxRetries: asNumber(configFm.max_retries, 1), + skills: asStringArray(agentFm.skills), + mcpServers: asStringArray(agentFm.mcp_servers), + cwd: asString(configFm.cwd) || asString(agentFm.cwd), + enabled: asBoolean(agentFm.enabled, true), + timeout: asNumber(configFm.timeout, asNumber(agentFm.timeout, 300)), + approvalRequired: asStringArray(configFm.approval_required), + memory: asBoolean(configFm.memory, asBoolean(agentFm.memory, false)), + memoryMaxEntries: asNumber(configFm.memory_max_entries, 100), + memoryTokenBudget: resolveMemoryTokenBudget(configFm, agentFm), + reflection: parseReflectionConfig(configFm, agentFm), + autoCompactThreshold: asNumber( + configFm.auto_compact_threshold ?? agentFm.auto_compact_threshold, + 85, + ), + tags: asStringArray(agentFm.tags), + avatar: asString(agentFm.avatar) ?? "", + body: agentBody, + contextBody, + skillsBody, + env: parseEnvMap(configFm.env), + permissionRules, + isFolder: true, + heartbeatEnabled, + heartbeatSchedule, + heartbeatBody, + heartbeatNotify, + heartbeatChannel, + heartbeatChannelTarget, + wikiKeeper: parseWikiKeeperConfig(configFm.wiki_keeper ?? agentFm.wiki_keeper), + wikiReferences: parseWikiReferences(configFm.wiki_references ?? agentFm.wiki_references), + }; +} diff --git a/src/repository/entityStore.ts b/src/repository/entityStore.ts new file mode 100644 index 0000000..9b172cc --- /dev/null +++ b/src/repository/entityStore.ts @@ -0,0 +1,598 @@ +import { TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import type { FLEET_SUBFOLDERS } from "../constants"; +import { asStringArray } from "./shared"; +import { + loadFolderAgent, + loadFolderSkill, + parseAgent, + parseChannelFile, + parseSkill, + parseTask, + sidecarPermissionsPath, +} from "./entityParsers"; +import type { ParsedEntity, ParserContext } from "./entityParsers"; +import type { + AgentConfig, + ChannelConfig, + ChannelCredentialEntry, + FleetSettings, + FleetSnapshot, + McpServer, + SkillConfig, + TaskConfig, + ValidationIssue, +} from "../types"; + +/** A path-keyed entity map that notifies on every mutation. The store's + * name-keyed lookup indexes are invalidated through this callback, so no + * mutation site (load, reload, delete, clear) can ever be missed — staleness + * is impossible by construction rather than by auditing call sites. */ +class MutationTrackedMap extends Map { + constructor(private readonly onMutate: () => void) { + super(); + } + override set(key: string, value: V): this { + this.onMutate(); + return super.set(key, value); + } + override delete(key: string): boolean { + this.onMutate(); + return super.delete(key); + } + override clear(): void { + this.onMutate(); + super.clear(); + } +} + +/** + * The entity cluster extracted from the FleetRepository facade: in-memory + * agent/skill/task/channel/MCP-server maps, the load flow (flat + folder + * entities), the two-tier validation-issue bookkeeping, and cross-entity + * reference validation. The facade delegates here and keeps only wiring and + * cross-store operations. Persisting mutations (create/update/delete entity + * files) lives in EntityMutations, which drives this store's maps through + * loadFile/clearStoredFile/validateReferences. + */ +export class EntityStore { + private agents = new MutationTrackedMap(() => { + this.nameIndexesStale = true; + }); + private skills = new MutationTrackedMap(() => { + this.nameIndexesStale = true; + }); + private tasks = new MutationTrackedMap(() => { + this.nameIndexesStale = true; + }); + private channels = new Map(); + private mcpServers = new Map(); + /** Load-time issues (parse failures, corrupt permission/sidecar files), + * keyed by file path. Set while loading individual files and cleared only + * when that file is re-parsed or removed. */ + private validationIssues = new Map(); + /** Cross-entity REFERENCE issues (missing skill/agent/credential, duplicate + * names), keyed by file path. Kept separate from {@link validationIssues} + * so {@link validateReferences} can be re-run after any mutation: it clears + * and recomputes only this map, never touching (or duplicating alongside) + * the load-time corrupt-file issues. */ + private referenceIssues = new Map(); + /** True while loadAll() streams files in — suppresses the per-mutation + * validateReferences() so a bulk load validates once at the end instead of + * once per file. */ + private bulkLoading = false; + + /** Lazily rebuilt name-keyed indexes over agents/skills/tasks. Keeps the + * FIRST entity per name (insertion order) so duplicate names resolve the + * same way the previous linear `find()` scans did. */ + private nameIndexesStale = true; + private agentsByName = new Map(); + private skillsByName = new Map(); + private tasksById = new Map(); + + private channelCredentialGetter?: () => Record; + + /** Agents we've already logged a folder-model-conflict warning for. */ + private warnedFolderAgentModelConflict = new Set(); + private warnedLegacyPerms = new Set(); + + private readonly vault: Vault; + private readonly settings: FleetSettings; + /** Context handed to the extracted entity parsers — same surface the + * parsers had as private facade methods (vault reads, issue reporting into + * {@link validationIssues}, the one-time-warning sets). */ + private readonly parserCtx: ParserContext; + + constructor( + app: App, + private readonly deps: { + settings: FleetSettings; + getFleetRoot: () => string; + getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string; + /** MCP registry files are parsed by McpRegistry; the store only owns the + * in-memory server map. Routed through the facade so registry parse + * errors keep landing in this store's validation-issue map. */ + parseMcpServerFile: (path: string, content: string) => McpServer | null; + }, + ) { + this.vault = app.vault; + this.settings = deps.settings; + this.parserCtx = { + vault: this.vault, + settings: this.settings, + reportIssue: (path, message) => this.setIssue(path, message), + clearIssue: (path) => { + this.validationIssues.delete(path); + }, + warnedLegacyPerms: this.warnedLegacyPerms, + warnedFolderAgentModelConflict: this.warnedFolderAgentModelConflict, + }; + } + + /** Inject a live credential getter so validation reads from the credential store + * instead of the (possibly empty post-migration) settings.channelCredentials. */ + setChannelCredentialGetter(getter: () => Record): void { + this.channelCredentialGetter = getter; + } + + async loadAll(): Promise { + this.agents.clear(); + this.skills.clear(); + this.tasks.clear(); + this.channels.clear(); + this.mcpServers.clear(); + this.validationIssues.clear(); + this.referenceIssues.clear(); + + this.bulkLoading = true; + try { + // Load folder-based agents and skills first + await this.loadFolderAgents(); + await this.loadFolderSkills(); + + const files = this.vault + .getMarkdownFiles() + .filter((file) => file.path.startsWith(`${this.deps.getFleetRoot()}/`)); + + for (const file of files) { + await this.loadFile(file); + } + } finally { + this.bulkLoading = false; + } + + this.validateReferences(); + return this.getSnapshot(); + } + + async loadFile(fileOrPath: TFile | string): Promise { + await this.loadFileInternal(fileOrPath); + // Entity references may have changed (e.g. an agent's skills list edited on + // disk, a task retargeted) — recompute reference issues immediately instead + // of waiting for the next full loadAll(). Skipped inside loadAll's own loop + // (validated once at the end). Cheap: validateReferences is a pure in-memory + // pass over the loaded maps. + if (!this.bulkLoading) this.validateReferences(); + } + + private async loadFileInternal(fileOrPath: TFile | string): Promise { + const file = typeof fileOrPath === "string" ? this.vault.getAbstractFileByPath(fileOrPath) : fileOrPath; + if (!(file instanceof TFile) || file.extension !== "md") { + return; + } + + // Files inside agent folders need to reload the whole folder agent + if (this.isInsideAgentFolder(file.path)) { + await this.reloadFolderAgentContaining(file.path); + return; + } + + // Files inside skill folders need to reload the whole folder skill + if (this.isInsideSkillFolder(file.path)) { + await this.reloadFolderSkillContaining(file.path); + return; + } + + this.clearStoredFile(file.path); + + // Channel files live under _fleet/channels/ and are parsed through a dedicated + // path that does NOT flow through parseFile(). This keeps channels out of the + // ParsedEntity union (AgentConfig | SkillConfig | TaskConfig) and lets their + // validation (type discrimination, approval_required block) live beside the + // parser. + const channelsPrefix = `${this.deps.getSubfolder("channels")}/`; + if (file.path.startsWith(channelsPrefix)) { + // Skip nested files (e.g. sessions/.json). Only top-level *.md are channel configs. + const rel = file.path.slice(channelsPrefix.length); + if (!rel.includes("/")) { + const content = await this.vault.cachedRead(file); + const channel = parseChannelFile(this.parserCtx, file.path, content); + if (channel) { + this.channels.set(file.path, channel); + } + } + return; + } + + // MCP server registry files live under _fleet/mcp/ and, like channels, are + // parsed through a dedicated path that does NOT flow through parseFile() + // (they're not part of the ParsedEntity union). + const mcpPrefix = `${this.deps.getSubfolder("mcp")}/`; + if (file.path.startsWith(mcpPrefix)) { + const rel = file.path.slice(mcpPrefix.length); + if (!rel.includes("/")) { + const content = await this.vault.cachedRead(file); + const server = this.deps.parseMcpServerFile(file.path, content); + if (server) { + this.mcpServers.set(file.path, server); + } + } + return; + } + + const content = await this.vault.cachedRead(file); + const parsed = this.parseFile(file.path, content); + if (parsed) { + if ("taskId" in parsed) { + this.tasks.set(file.path, parsed); + } else if ("model" in parsed) { + // Flat agent: sidecar `.permissions.json` wins over legacy + // frontmatter allowed_tools/blocked_tools when both exist. + if (!parsed.isFolder) { + const sidecarPath = sidecarPermissionsPath(file.path); + // clearStoredFile above only clears issues keyed on the .md path; + // drop any stale sidecar issue before re-evaluating the file. + this.validationIssues.delete(sidecarPath); + const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath); + if (sidecarFile instanceof TFile) { + try { + const sidecarContent = await this.vault.cachedRead(sidecarFile); + const data = JSON.parse(sidecarContent) as Record; + parsed.permissionRules = { + allow: asStringArray(data.allow), + deny: asStringArray(data.deny), + }; + } catch (err) { + // Invalid sidecar JSON — keep whatever permissionRules parseAgent assigned. + console.error( + `Agent Fleet: invalid JSON in ${sidecarPath} — agent "${parsed.name}" falls back to legacy frontmatter permission rules`, + err, + ); + this.setIssue( + sidecarPath, + `Invalid JSON in ${sidecarPath} for agent \`${parsed.name}\` — sidecar permission rules are NOT applied.`, + ); + } + } + } + this.agents.set(file.path, parsed); + } else { + this.skills.set(file.path, parsed); + } + } + } + + private async reloadFolderAgentContaining(filePath: string): Promise { + const agentsDir = `${this.deps.getSubfolder("agents")}/`; + const relative = filePath.slice(agentsDir.length); + const folderName = relative.split("/")[0]; + if (!folderName) return; + + const folderPath = normalizePath(`${agentsDir}${folderName}`); + const agentMdPath = normalizePath(`${folderPath}/agent.md`); + + // Clear any existing entry for this folder agent + this.agents.delete(agentMdPath); + + const folder = this.vault.getAbstractFileByPath(folderPath); + if (!(folder instanceof TFolder)) return; + + const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath); + if (!(agentMdFile instanceof TFile)) return; + + const agent = await loadFolderAgent(this.parserCtx, folderPath, agentMdFile); + if (agent) { + this.agents.set(agentMdPath, agent); + } + } + + private isInsideAgentFolder(path: string): boolean { + const agentsDir = `${this.deps.getSubfolder("agents")}/`; + if (!path.startsWith(agentsDir)) return false; + const relative = path.slice(agentsDir.length); + return relative.includes("/"); + } + + private isInsideSkillFolder(path: string): boolean { + const skillsDir = `${this.deps.getSubfolder("skills")}/`; + if (!path.startsWith(skillsDir)) return false; + const relative = path.slice(skillsDir.length); + return relative.includes("/"); + } + + private async reloadFolderSkillContaining(filePath: string): Promise { + const skillsDir = `${this.deps.getSubfolder("skills")}/`; + const relative = filePath.slice(skillsDir.length); + const folderName = relative.split("/")[0]; + if (!folderName) return; + + const folderPath = normalizePath(`${skillsDir}${folderName}`); + const skillMdPath = normalizePath(`${folderPath}/skill.md`); + + this.skills.delete(skillMdPath); + + const folder = this.vault.getAbstractFileByPath(folderPath); + if (!(folder instanceof TFolder)) return; + + const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath); + if (!(skillMdFile instanceof TFile)) return; + + const skill = await loadFolderSkill(this.parserCtx, folderPath, skillMdFile); + if (skill) { + this.skills.set(skillMdPath, skill); + } + } + + private async loadFolderSkills(): Promise { + const skillsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("skills")); + if (!(skillsFolder instanceof TFolder)) return; + + for (const child of skillsFolder.children) { + if (!(child instanceof TFolder)) continue; + const skillMdPath = normalizePath(`${child.path}/skill.md`); + const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath); + if (!(skillMdFile instanceof TFile)) continue; + + const skill = await loadFolderSkill(this.parserCtx, child.path, skillMdFile); + if (skill) { + this.skills.set(skillMdPath, skill); + } + } + } + + private async loadFolderAgents(): Promise { + const agentsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("agents")); + if (!(agentsFolder instanceof TFolder)) return; + + for (const child of agentsFolder.children) { + if (!(child instanceof TFolder)) continue; + const agentMdPath = normalizePath(`${child.path}/agent.md`); + const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath); + if (!(agentMdFile instanceof TFile)) continue; + + const agent = await loadFolderAgent(this.parserCtx, child.path, agentMdFile); + if (agent) { + this.agents.set(agentMdPath, agent); + } + } + } + + removeFile(path: string): void { + this.clearStoredFile(path); + // A removed entity can orphan references pointing at it — revalidate now. + if (!this.bulkLoading) this.validateReferences(); + } + + getSnapshot(): FleetSnapshot { + return { + agents: Array.from(this.agents.values()).sort((a, b) => a.name.localeCompare(b.name)), + skills: Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name)), + tasks: Array.from(this.tasks.values()).sort((a, b) => a.taskId.localeCompare(b.taskId)), + channels: Array.from(this.channels.values()).sort((a, b) => a.name.localeCompare(b.name)), + mcpServers: Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)), + validationIssues: [ + ...Array.from(this.validationIssues.values()).flat(), + ...Array.from(this.referenceIssues.values()).flat(), + ], + }; + } + + private rebuildNameIndexes(): void { + if (!this.nameIndexesStale) return; + this.agentsByName.clear(); + this.skillsByName.clear(); + this.tasksById.clear(); + for (const agent of this.agents.values()) { + if (!this.agentsByName.has(agent.name)) this.agentsByName.set(agent.name, agent); + } + for (const skill of this.skills.values()) { + if (!this.skillsByName.has(skill.name)) this.skillsByName.set(skill.name, skill); + } + for (const task of this.tasks.values()) { + if (!this.tasksById.has(task.taskId)) this.tasksById.set(task.taskId, task); + } + this.nameIndexesStale = false; + } + + getAgentByName(name: string): AgentConfig | undefined { + this.rebuildNameIndexes(); + return this.agentsByName.get(name); + } + + getSkillByName(name: string): SkillConfig | undefined { + this.rebuildNameIndexes(); + return this.skillsByName.get(name); + } + + getTaskById(taskId: string): TaskConfig | undefined { + this.rebuildNameIndexes(); + return this.tasksById.get(taskId); + } + + getTasksForAgent(agentName: string): TaskConfig[] { + return Array.from(this.tasks.values()).filter((task) => task.agent === agentName); + } + + getChannelByName(name: string): ChannelConfig | undefined { + return Array.from(this.channels.values()).find((channel) => channel.name === name); + } + + getChannelsForAgent(agentName: string): ChannelConfig[] { + return Array.from(this.channels.values()).filter((channel) => channel.defaultAgent === agentName); + } + + /** All registered MCP servers, sorted by name. */ + getMcpServers(): McpServer[] { + return Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)); + } + + getMcpServerByName(name: string): McpServer | undefined { + return Array.from(this.mcpServers.values()).find((s) => s.name === name); + } + + /** Drop every in-memory entity and issue keyed on `path`. Public within the + * repository layer: EntityMutations drives it on delete paths. */ + clearStoredFile(path: string): void { + this.agents.delete(path); + this.skills.delete(path); + this.tasks.delete(path); + this.channels.delete(path); + this.mcpServers.delete(path); + this.validationIssues.delete(path); + this.referenceIssues.delete(path); + } + + /** Drop every stored issue keyed under `folderPath/` — folder agents/skills + * carry issues keyed on member files (e.g. permissions.json), which + * clearStoredFile(.md) alone would leave behind after a delete. */ + clearIssuesUnder(folderPath: string): void { + const prefix = `${folderPath}/`; + for (const key of [...this.validationIssues.keys()]) { + if (key.startsWith(prefix)) this.validationIssues.delete(key); + } + for (const key of [...this.referenceIssues.keys()]) { + if (key.startsWith(prefix)) this.referenceIssues.delete(key); + } + } + + /** Drop both load-time and reference issues keyed on exactly `path` — used + * when deleting a flat agent to clear its permissions-sidecar issues. */ + clearIssuesFor(path: string): void { + this.validationIssues.delete(path); + this.referenceIssues.delete(path); + } + + /** Record a LOAD-TIME issue (parse failure, corrupt permission/sidecar + * file). Public within the repository layer: McpRegistry reports its parse + * errors here (wired through the facade) so they land in the same map as + * every other parser's. */ + setIssue(path: string, message: string): void { + const issues = this.validationIssues.get(path) ?? []; + issues.push({ path, message }); + this.validationIssues.set(path, issues); + } + + /** Record a cross-entity reference issue. Lives in {@link referenceIssues} + * (recomputed wholesale by validateReferences), NOT in the load-time + * {@link validationIssues} map. */ + private setReferenceIssue(path: string, message: string): void { + const issues = this.referenceIssues.get(path) ?? []; + issues.push({ path, message }); + this.referenceIssues.set(path, issues); + } + + private parseFile(path: string, content: string): ParsedEntity | null { + if (path.startsWith(`${this.deps.getSubfolder("agents")}/`)) { + return parseAgent(this.parserCtx, path, content); + } + if (path.startsWith(`${this.deps.getSubfolder("skills")}/`)) { + return parseSkill(this.parserCtx, path, content); + } + if (path.startsWith(`${this.deps.getSubfolder("tasks")}/`)) { + return parseTask(this.parserCtx, path, content); + } + return null; + } + + /** + * Recompute every cross-entity reference issue from the in-memory maps. + * Pure in-memory pass (no vault I/O — only the loaded maps plus the injected + * credential getter), so it is cheap enough to re-run after every mutation. + * Idempotent: {@link referenceIssues} is cleared and rebuilt wholesale each + * run; load-time corrupt-file issues live in the separate + * {@link validationIssues} map and are never touched here. + * + * Public within the repository layer: EntityMutations re-runs it after each + * delete once all map mutations are done. + */ + validateReferences(): void { + this.referenceIssues.clear(); + + const skillNames = new Set(); + for (const skill of this.skills.values()) { + if (skillNames.has(skill.name)) { + this.setReferenceIssue(skill.filePath, `Duplicate skill name \`${skill.name}\`.`); + } + skillNames.add(skill.name); + } + + const agentNames = new Set(); + for (const agent of this.agents.values()) { + if (agentNames.has(agent.name)) { + this.setReferenceIssue(agent.filePath, `Duplicate agent name \`${agent.name}\`.`); + } + agentNames.add(agent.name); + for (const skillName of agent.skills) { + if (!skillNames.has(skillName)) { + this.setReferenceIssue(agent.filePath, `Agent references missing skill \`${skillName}\`.`); + } + } + } + + for (const task of this.tasks.values()) { + if (!agentNames.has(task.agent)) { + this.setReferenceIssue(task.filePath, `Task references missing agent \`${task.agent}\`.`); + } + } + + const channelNames = new Set(); + const agentsByName = new Map(); + for (const agent of this.agents.values()) { + agentsByName.set(agent.name, agent); + } + const credentials = this.channelCredentialGetter?.() ?? this.settings.channelCredentials ?? {}; + + for (const channel of this.channels.values()) { + if (channelNames.has(channel.name)) { + this.setReferenceIssue(channel.filePath, `Duplicate channel name \`${channel.name}\`.`); + } + channelNames.add(channel.name); + + const boundAgent = agentsByName.get(channel.defaultAgent); + if (!boundAgent) { + this.setReferenceIssue( + channel.filePath, + `Channel \`${channel.name}\` references missing agent \`${channel.defaultAgent}\`.`, + ); + } else if (boundAgent.approvalRequired.length > 0) { + // Channels have no approval UI; a channel turn needing approval would deadlock. + // Block the binding at load time — the channel loads but is treated as disabled. + this.setReferenceIssue( + channel.filePath, + `Channel \`${channel.name}\` cannot bind to agent \`${boundAgent.name}\` because the agent has \`approval_required\` set (${boundAgent.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`, + ); + } + + const credential = credentials[channel.credentialRef]; + if (!credential) { + this.setReferenceIssue( + channel.filePath, + `Channel \`${channel.name}\` references missing credential \`${channel.credentialRef}\`. Add it under Settings → Channel Credentials.`, + ); + } else if (credential.type !== channel.type) { + this.setReferenceIssue( + channel.filePath, + `Channel \`${channel.name}\` is type \`${channel.type}\` but credential \`${channel.credentialRef}\` is type \`${credential.type}\`.`, + ); + } + } + + const mcpNames = new Set(); + for (const server of this.mcpServers.values()) { + if (mcpNames.has(server.name)) { + this.setReferenceIssue(server.filePath ?? server.name, `Duplicate MCP server name \`${server.name}\`.`); + } + mcpNames.add(server.name); + } + } +} diff --git a/src/repository/mcpRegistry.test.ts b/src/repository/mcpRegistry.test.ts new file mode 100644 index 0000000..26addc7 --- /dev/null +++ b/src/repository/mcpRegistry.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { App } from "obsidian"; +import type { ValidationIssue } from "../types"; +import { McpRegistry } from "./mcpRegistry"; +import { FakeVault, makeApp } from "./testSupport"; + +describe("McpRegistry", () => { + let vault: FakeVault; + let issues: ValidationIssue[]; + let registry: McpRegistry; + + beforeEach(() => { + vault = new FakeVault(); + issues = []; + registry = new McpRegistry(makeApp(vault) as App, { + getMcpDir: () => "_fleet/mcp", + getServerByName: () => undefined, + reportIssue: (path, message) => issues.push({ path, message }), + }); + }); + + describe("parseMcpServerFile", () => { + it("parses a valid stdio server with defaults", () => { + const server = registry.parseMcpServerFile( + "_fleet/mcp/files.md", + "---\nname: files\ntransport: stdio\ncommand: npx\n---\n\nLocal file server\n", + ); + expect(server).not.toBeNull(); + expect(server?.type).toBe("stdio"); + expect(server?.enabled).toBe(true); // default + expect(server?.source).toBe("manual"); // default + expect(server?.description).toBe("Local file server"); // body fallback + expect(issues).toHaveLength(0); + }); + + it("accepts `type` as a transport alias", () => { + const server = registry.parseMcpServerFile( + "_fleet/mcp/api.md", + "---\nname: api\ntype: http\nurl: https://example.com/mcp\n---\n", + ); + expect(server?.type).toBe("http"); + expect(issues).toHaveLength(0); + }); + + it("rejects a missing/invalid transport and records an issue", () => { + const server = registry.parseMcpServerFile( + "_fleet/mcp/bad.md", + "---\nname: bad\ntransport: carrier-pigeon\n---\n", + ); + expect(server).toBeNull(); + expect(issues).toHaveLength(1); + expect(issues[0]?.path).toBe("_fleet/mcp/bad.md"); + }); + + it("rejects stdio without command and http without url", () => { + expect( + registry.parseMcpServerFile("_fleet/mcp/a.md", "---\nname: a\ntransport: stdio\n---\n"), + ).toBeNull(); + expect( + registry.parseMcpServerFile("_fleet/mcp/b.md", "---\nname: b\ntransport: http\n---\n"), + ).toBeNull(); + expect(issues).toHaveLength(2); + }); + }); + + describe("saveMcpServer", () => { + it("creates a new registry file with a de-duplicated slug path", async () => { + vault.addFile("_fleet/mcp/files.md", "---\nname: files\ntransport: stdio\ncommand: old\n---\n"); + const path = await registry.saveMcpServer({ + name: "files", + type: "stdio", + enabled: true, + source: "manual", + command: "npx", + args: [], + status: "disconnected", + scope: "user", + tools: [], + toolDetails: [], + }); + expect(path).toBe("_fleet/mcp/files-2.md"); // existing file → suffixed + expect(vault.contents.get(path)).toContain("command: npx"); + }); + + it("rewrites in place when filePath is set", async () => { + vault.addFile("_fleet/mcp/files.md", "---\nname: files\ntransport: stdio\ncommand: old\n---\n"); + const path = await registry.saveMcpServer({ + name: "files", + filePath: "_fleet/mcp/files.md", + type: "stdio", + enabled: false, + source: "manual", + command: "npx", + args: [], + status: "disconnected", + scope: "user", + tools: [], + toolDetails: [], + }); + expect(path).toBe("_fleet/mcp/files.md"); + expect(vault.contents.get(path)).toContain("enabled: false"); + }); + }); +}); diff --git a/src/repository/mcpRegistry.ts b/src/repository/mcpRegistry.ts new file mode 100644 index 0000000..fafde90 --- /dev/null +++ b/src/repository/mcpRegistry.ts @@ -0,0 +1,170 @@ +import { TFile } from "obsidian"; +import type { App, Vault } from "obsidian"; +import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown"; +import type { McpServer } from "../types"; +import { asBoolean, asString, asStringArray, asStringMap, ensureFolder, getAvailablePath, isRecord, trashPath } from "./shared"; + +/** + * MCP server registry: parse + persist `_fleet/mcp/.md` files. + * Extracted verbatim from FleetRepository. The facade still owns the + * in-memory server map (load flow + snapshot); parse errors are reported + * through the injected `reportIssue` so they land in the same + * validation-issue map as every other parser. + */ +export class McpRegistry { + private readonly vault: Vault; + + constructor( + private readonly app: App, + private readonly deps: { + getMcpDir: () => string; + getServerByName: (name: string) => McpServer | undefined; + reportIssue: (path: string, message: string) => void; + }, + ) { + this.vault = app.vault; + } + + /** + * Parse one `_fleet/mcp/.md` registry file. The frontmatter holds the + * non-secret server definition; the body is a human description. Secrets + * (bearer/OAuth tokens, secret env values) are NEVER read here — they live in + * SecretStore and are resolved at projection time. + */ + parseMcpServerFile(path: string, content: string): McpServer | null { + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + + const name = asString(frontmatter.name); + if (!name) { + this.deps.reportIssue(path, "MCP server requires string field `name`."); + return null; + } + + // `transport` is the canonical frontmatter key; `type` is accepted as an + // alias (matches the native config vocabulary). + const rawTransport = (asString(frontmatter.transport) ?? asString(frontmatter.type) ?? "").toLowerCase(); + const validTransports: readonly string[] = ["stdio", "http", "sse"]; + if (!validTransports.includes(rawTransport)) { + this.deps.reportIssue( + path, + `MCP server \`${name}\` requires \`transport\` to be one of: ${validTransports.join(", ")}.`, + ); + return null; + } + const type = rawTransport as McpServer["type"]; + + if (type === "stdio") { + if (!asString(frontmatter.command)) { + this.deps.reportIssue(path, `MCP server \`${name}\` (stdio) requires a \`command\`.`); + return null; + } + } else if (!asString(frontmatter.url)) { + this.deps.reportIssue(path, `MCP server \`${name}\` (${type}) requires a \`url\`.`); + return null; + } + + const rawAuth = (asString(frontmatter.auth) ?? "").toLowerCase(); + const auth: McpServer["auth"] = + rawAuth === "bearer" || rawAuth === "oauth" || rawAuth === "none" ? rawAuth : undefined; + + let oauth: McpServer["oauth"]; + if (isRecord(frontmatter.oauth)) { + const o = frontmatter.oauth; + oauth = { + clientId: asString(o.client_id) ?? asString(o.clientId), + resource: asString(o.resource), + scopes: asStringArray(o.scopes), + }; + } + + return { + name, + filePath: path, + type, + enabled: asBoolean(frontmatter.enabled, true), + description: asString(frontmatter.description) ?? (body.trim() || undefined), + source: asString(frontmatter.source) === "imported" ? "imported" : "manual", + command: asString(frontmatter.command), + args: asStringArray(frontmatter.args), + env: asStringMap(frontmatter.env), + envSecretKeys: asStringArray(frontmatter.env_secret_keys), + url: asString(frontmatter.url), + headers: asStringMap(frontmatter.headers), + auth, + oauth, + // Runtime-only fields — filled in by an on-demand probe, not persisted. + status: "disconnected", + scope: "user", + tools: [], + toolDetails: [], + }; + } + + /** Build `_fleet/mcp/.md` frontmatter from a server definition, + * omitting empty/runtime fields. Secrets are never written here. */ + private mcpServerFrontmatter(server: McpServer): Record { + const fm: Record = { + name: server.name, + transport: server.type, + enabled: server.enabled, + }; + if (server.source) fm.source = server.source; + if (server.type === "stdio") { + if (server.command) fm.command = server.command; + if (server.args && server.args.length > 0) fm.args = server.args; + if (server.env && Object.keys(server.env).length > 0) fm.env = server.env; + if (server.envSecretKeys && server.envSecretKeys.length > 0) fm.env_secret_keys = server.envSecretKeys; + } else { + if (server.url) fm.url = server.url; + if (server.headers && Object.keys(server.headers).length > 0) fm.headers = server.headers; + if (server.auth) fm.auth = server.auth; + if (server.oauth && (server.oauth.clientId || server.oauth.resource || (server.oauth.scopes?.length ?? 0) > 0)) { + fm.oauth = { + client_id: server.oauth.clientId || undefined, + resource: server.oauth.resource || undefined, + scopes: server.oauth.scopes && server.oauth.scopes.length > 0 ? server.oauth.scopes : undefined, + }; + } + } + return fm; + } + + /** + * Create or update an MCP server registry file. When `server.filePath` is set + * the existing file is rewritten in place; otherwise a new + * `_fleet/mcp/.md` is created. Returns the file path. Secrets are NOT + * handled here — callers store tokens/secret env values in SecretStore. + */ + async saveMcpServer(server: McpServer, body = ""): Promise { + const fm = this.mcpServerFrontmatter(server); + const content = stringifyMarkdownWithFrontmatter(fm, body.trim()); + const existing = server.filePath ? this.vault.getAbstractFileByPath(server.filePath) : null; + if (existing instanceof TFile) { + await this.vault.modify(existing, content); + return existing.path; + } + const path = await getAvailablePath(this.vault, this.deps.getMcpDir(), slugify(server.name)); + await ensureFolder(this.vault, this.deps.getMcpDir()); + await this.vault.create(path, content); + return path; + } + + /** Flip an MCP server's `enabled` flag in its registry file. */ + async setMcpServerEnabled(name: string, enabled: boolean): Promise { + const server = this.deps.getServerByName(name); + if (!server?.filePath) return; + const file = this.vault.getAbstractFileByPath(server.filePath); + if (!(file instanceof TFile)) return; + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + frontmatter.enabled = enabled; + await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, body)); + } + + /** Trash an MCP server's registry file. */ + async deleteMcpServer(name: string): Promise { + const server = this.deps.getServerByName(name); + if (!server?.filePath) return; + await trashPath(this.app, server.filePath); + } +} diff --git a/src/repository/memoryStore.test.ts b/src/repository/memoryStore.test.ts new file mode 100644 index 0000000..1e547c8 --- /dev/null +++ b/src/repository/memoryStore.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MockInstance } from "vitest"; + +// memoryStore references FileSystemAdapter at runtime (instanceof); the shared +// obsidian stub lacks it — extend it here, mirroring fleetRepository.test.ts. +vi.mock("obsidian", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + App: class App {}, + FileSystemAdapter: class FileSystemAdapter {}, + }; +}); + +import { MEMORY_SCHEMA_VERSION, emptyWorkingMemory } from "../utils/memoryFormat"; +import { MemoryStore } from "./memoryStore"; +import { FakeVault, makeApp } from "./testSupport"; + +const WORKING = "_fleet/memory/bot/working.md"; + +describe("MemoryStore working-memory schema guard", () => { + let vault: FakeVault; + let store: MemoryStore; + let warnSpy: MockInstance; + + beforeEach(() => { + vault = new FakeVault(); + store = new MemoryStore(makeApp(vault), () => "_fleet/memory"); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses a current-schema working.md normally", async () => { + vault.addFile( + WORKING, + `---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION}\n---\n\n## Observations\n- knows things\n`, + ); + const wm = await store.readWorkingMemory("bot"); + expect(wm?.schema).toBe(MEMORY_SCHEMA_VERSION); + expect(wm?.sections[0]?.entries[0]?.text).toBe("knows things"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("treats a missing schema field as current (pre-schema files keep working)", async () => { + vault.addFile(WORKING, "---\nagent: bot\n---\n\n## Observations\n- old fact\n"); + const wm = await store.readWorkingMemory("bot"); + expect(wm?.schema).toBe(MEMORY_SCHEMA_VERSION); + expect(wm?.sections[0]?.entries[0]?.text).toBe("old fact"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("keeps parsing an OLDER-but-known numeric schema (current behavior)", async () => { + vault.addFile(WORKING, "---\nagent: bot\nschema: 1\n---\n\n## Observations\n- v1 fact\n"); + const wm = await store.readWorkingMemory("bot"); + expect(wm?.schema).toBe(1); + expect(wm?.sections[0]?.entries[0]?.text).toBe("v1 fact"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("warns and returns null on a NEWER schema instead of misparsing it", async () => { + vault.addFile( + WORKING, + `---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION + 1}\n---\n\n## Observations\n- future fact\n`, + ); + expect(await store.readWorkingMemory("bot")).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + // getMemory (the back-compat shim) inherits the guard. + expect(await store.getMemory("bot")).toBeNull(); + }); + + it("warns and returns null on a non-numeric schema marker", async () => { + vault.addFile(WORKING, "---\nagent: bot\nschema: v9-beta\n---\n\n## Observations\n- weird\n"); + expect(await store.readWorkingMemory("bot")).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("refuses to overwrite a newer-schema working.md (never destroys data)", async () => { + const newer = + `---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION + 1}\n---\n\n## Observations\n- future fact\n`; + vault.addFile(WORKING, newer); + + // Simulate the capture path after the read guard fired: read → null → + // rebuild from scratch → write. The write must be a warned no-op. + const rebuilt = emptyWorkingMemory(WORKING, "bot"); + await store.writeWorkingMemory("bot", rebuilt); + + expect(vault.contents.get(WORKING)).toBe(newer); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("still writes normally over a current-schema file", async () => { + vault.addFile( + WORKING, + `---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION}\n---\n\n## Observations\n- old\n`, + ); + const wm = await store.readWorkingMemory("bot"); + expect(wm).not.toBeNull(); + await store.writeWorkingMemory("bot", { + ...wm!, + sections: [{ name: "Observations", entries: [{ text: "new fact", pinned: false }] }], + }); + expect(vault.contents.get(WORKING)).toContain("new fact"); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/repository/memoryStore.ts b/src/repository/memoryStore.ts new file mode 100644 index 0000000..053a45e --- /dev/null +++ b/src/repository/memoryStore.ts @@ -0,0 +1,367 @@ +import { join } from "path"; +import { FileSystemAdapter, TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown"; +import { + MEMORY_SCHEMA_VERSION, + migrateLegacyBody, + parseWorkingMemory, + renderSections, + serializeWorkingMemory, +} from "../utils/memoryFormat"; +import type { MemoryFile, SkillCandidate, WorkingMemory } from "../types"; +import { createFileIfMissing, ensureFolder, trashPath } from "./shared"; + +/** + * Agent memory store: v2 folder layout (working.md, raw/ archive, pending/ + * capture inbox, candidates.json), the legacy flat-file layout, and the + * legacy→v2 migration — including the in-flight migration promise map. + * Extracted verbatim from FleetRepository. + */ +export class MemoryStore { + /** In-flight legacy→v2 memory migrations keyed by agent name, so the + * unlocked reflection path and the lock-held capture path share a single + * migration: concurrent callers await the same promise instead of either + * double-seeding raw or returning before the migration has finished. */ + private migratingMemory = new Map>(); + + private readonly vault: Vault; + + constructor( + private readonly app: App, + /** `_fleet/memory` (lazy — the fleet folder is a live setting). */ + private readonly getMemoryRoot: () => string, + ) { + this.vault = app.vault; + } + + /** @deprecated Legacy flat memory file path. New layout uses + * {@link getWorkingMemoryPath}. Retained for migration + back-compat. */ + getMemoryPath(agentName: string): string { + return normalizePath(`${this.getMemoryRoot()}/${slugify(agentName)}.md`); + } + + /** Folder holding an agent's v2 memory: working.md, candidates.md, raw/. */ + getMemoryDir(agentName: string): string { + return normalizePath(`${this.getMemoryRoot()}/${slugify(agentName)}`); + } + + /** Path to the curated, injected working-memory file (§6.2). */ + getWorkingMemoryPath(agentName: string): string { + return normalizePath(`${this.getMemoryDir(agentName)}/working.md`); + } + + /** Path to a day's append-only raw archive (§6.3). */ + getRawMemoryPath(agentName: string, dateIso: string): string { + const day = dateIso.slice(0, 10); // YYYY-MM-DD + return normalizePath(`${this.getMemoryDir(agentName)}/raw/${day}.md`); + } + + /** + * Read an agent's working memory as the structured v2 object. Resolves either + * layout: the v2 `working.md` if present, otherwise the legacy flat file + * migrated in-memory (the first write persists the folder layout). Returns + * null when the agent has no memory yet. + */ + async readWorkingMemory(agentName: string): Promise { + const workingPath = this.getWorkingMemoryPath(agentName); + const workingFile = this.vault.getAbstractFileByPath(workingPath); + if (workingFile instanceof TFile) { + const content = await this.vault.cachedRead(workingFile); + // Schema guard: a working.md written by a NEWER plugin version (or with a + // mangled `schema` field) must not be parsed as if it were the current + // format — fields could be silently misread. Fail soft the way the other + // corrupt-file reads here do (warn + treat as absent); the matching guard + // in writeWorkingMemory keeps the file from being clobbered afterwards. + if (this.hasIncompatibleSchema(content, workingPath)) return null; + return parseWorkingMemory(content, workingPath, agentName); + } + + const legacyPath = this.getMemoryPath(agentName); + const legacyFile = this.vault.getAbstractFileByPath(legacyPath); + if (legacyFile instanceof TFile) { + const content = await this.vault.cachedRead(legacyFile); + const { body } = parseMarkdownWithFrontmatter>(content); + return migrateLegacyBody(body, workingPath, agentName); + } + + return null; + } + + /** + * Persist working memory to `working.md`, creating the folder layout and + * trashing the legacy flat file if one still exists (completes migration). + */ + async writeWorkingMemory(agentName: string, wm: WorkingMemory): Promise { + const workingPath = this.getWorkingMemoryPath(agentName); + await ensureFolder(this.vault, this.getMemoryDir(agentName)); + const content = serializeWorkingMemory(wm); + const existing = this.vault.getAbstractFileByPath(workingPath); + if (existing instanceof TFile) { + // Write-side schema guard (pairs with readWorkingMemory): if the on-disk + // file carries a newer/unknown schema, readWorkingMemory returned null and + // `wm` was rebuilt from scratch — writing it would DESTROY the newer + // file's data. Skip the write instead; captures still land in the + // append-only raw archive, so no ground truth is lost. + if (this.hasIncompatibleSchema(await this.vault.cachedRead(existing), workingPath)) return; + await this.vault.modify(existing, content); + } else { + await createFileIfMissing(this.vault, workingPath, content); + } + + // Complete migration: retire the legacy flat file once v2 exists. + const legacyPath = this.getMemoryPath(agentName); + if (legacyPath !== workingPath && this.vault.getAbstractFileByPath(legacyPath)) { + await trashPath(this.app, legacyPath); + } + } + + /** + * True when the file's frontmatter `schema` is unrecognized: newer than this + * plugin's {@link MEMORY_SCHEMA_VERSION} or not a finite number at all. A + * MISSING field stays compatible — pre-schema files have always parsed as the + * current version (parseWorkingMemory defaults it), and changing that would + * orphan them. An older-but-known numeric schema also stays compatible + * (current behavior: parse it; the value is preserved on write). Warns once + * per call so both the read and write guards are loud in the console. + */ + private hasIncompatibleSchema(content: string, path: string): boolean { + const { frontmatter } = parseMarkdownWithFrontmatter>(content); + const raw = frontmatter.schema; + if (raw === undefined) return false; + if (typeof raw === "number" && Number.isFinite(raw) && raw <= MEMORY_SCHEMA_VERSION) return false; + console.warn( + `Agent Fleet: ${path} has unrecognized memory schema ${JSON.stringify(raw)} ` + + `(this plugin supports up to ${MEMORY_SCHEMA_VERSION}) — treating working memory as ` + + `unavailable and leaving the file untouched. Update the plugin to use this memory.`, + ); + return true; + } + + /** + * One-time migration of an agent's legacy flat memory file to the v2 folder + * layout. Crucially, the legacy content is SEEDED INTO THE RAW ARCHIVE first, + * so "ground truth is never destroyed" (§ design overview, §13.1) holds even + * if the first reflection later summarizes the working copy. Without this, a + * reflection running before any capture could permanently drop pre-v2 memory. + * No-op when already migrated (working.md exists) or there is no legacy file. + */ + async migrateLegacyMemory(agentName: string): Promise { + // A concurrent in-flight migration of the same agent (the reflection path + // calls this unlocked while a capture may run it under the MemoryWriter + // lock) is awaited rather than skipped — an early return would let the + // second caller proceed before the migration had actually completed. + const inFlight = this.migratingMemory.get(agentName); + if (inFlight) return inFlight; + const workingPath = this.getWorkingMemoryPath(agentName); + if (this.vault.getAbstractFileByPath(workingPath)) return; // already v2 + const legacyPath = this.getMemoryPath(agentName); + const legacyFile = this.vault.getAbstractFileByPath(legacyPath); + if (!(legacyFile instanceof TFile)) return; + const migration = (async () => { + const content = await this.vault.cachedRead(legacyFile); + const { body } = parseMarkdownWithFrontmatter>(content); + const nowIso = new Date().toISOString(); + + // 1. Seed the raw archive with the legacy content (permanent ground truth). + const seedLines = body + .split("\n") + .map((l) => l.replace(/^\s*[-*]\s+/, "").trim()) + .filter((l) => l.length > 0 && !/^#{1,6}\s/.test(l)); + const rawLines = [ + `- ${nowIso} [migrated] Imported ${seedLines.length} legacy memory ` + + `entr${seedLines.length === 1 ? "y" : "ies"} (pre-v2).`, + ...seedLines.map((t) => `- ${nowIso} [migrated] ${t}`), + ]; + await this.appendRawMemory(agentName, rawLines, nowIso); + + // 2. Write the v2 working memory; writeWorkingMemory trashes the legacy file. + const wm = migrateLegacyBody(body, workingPath, agentName, nowIso); + await this.writeWorkingMemory(agentName, wm); + })(); + this.migratingMemory.set(agentName, migration); + try { + await migration; + } finally { + this.migratingMemory.delete(agentName); + } + } + + /** Vault-relative path to the agent's MCP-tool capture inbox directory (§7.5). + * Each capture is written as its own JSON file inside it. */ + getPendingDir(agentName: string): string { + return normalizePath(`${this.getMemoryDir(agentName)}/pending`); + } + + /** Absolute on-disk path to the pending inbox dir, for the MCP server's env. + * Null when the vault is not a local filesystem (e.g. mobile). */ + getPendingDirAbsolutePath(agentName: string): string | null { + const adapter = this.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + return join(adapter.getBasePath(), this.getPendingDir(agentName)); + } + + /** Ensure the agent's memory folder exists. */ + async ensureMemoryDir(agentName: string): Promise { + await ensureFolder(this.vault, this.getMemoryDir(agentName)); + } + + /** + * Drain the MCP-tool capture inbox: read every JSON file the external server + * wrote, delete each one, and return their lines. Goes through the raw vault + * ADAPTER (not the TFile cache) because those files are created out-of-process + * and may not be in Obsidian's metadata index yet. Reading + deleting whole + * files means we never truncate a file mid-append, so a capture landing during + * a drain is just picked up by the next drain — no read-then-clear loss. + * Caller serializes via the MemoryWriter lock. + */ + async readAndClearPending(agentName: string): Promise { + const adapter = this.vault.adapter; + const dir = this.getPendingDir(agentName); + let files: string[]; + try { + if (!(await adapter.exists(dir))) return []; + files = (await adapter.list(dir)).files; + } catch { + return []; + } + const lines: string[] = []; + for (const filePath of files) { + if (!filePath.endsWith(".json")) continue; // ignore .tmp partials + try { + const content = await adapter.read(filePath); + // Remove BEFORE folding the lines in: if remove() fails we skip this + // file (retried next drain) rather than folding it now and re-folding + // the same capture next time — which would duplicate raw-archive lines. + await adapter.remove(filePath); + for (const l of content.split("\n")) if (l.trim().length > 0) lines.push(l); + } catch (err) { + // skip an unreadable / not-yet-removable file (picked up next drain) + console.warn(`Agent Fleet: skipping pending capture file ${filePath} (retried next drain)`, err); + } + } + return lines; + } + + /** Read the recent raw archive (last `days` daily files) for reflection. */ + async readRecentRaw(agentName: string, days = 2): Promise { + const dir = normalizePath(`${this.getMemoryDir(agentName)}/raw`); + const folder = this.vault.getAbstractFileByPath(dir); + if (!(folder instanceof TFolder)) return ""; + const files = folder.children + .filter((c): c is TFile => c instanceof TFile && c.extension === "md") + .sort((a, b) => b.name.localeCompare(a.name)) + .slice(0, days); + const parts: string[] = []; + for (const f of files.reverse()) { + parts.push(`### ${f.basename}\n${await this.vault.cachedRead(f)}`); + } + return parts.join("\n\n"); + } + + /** Path to the agent's skill-candidate ledger. */ + getCandidatesPath(agentName: string): string { + return normalizePath(`${this.getMemoryDir(agentName)}/candidates.json`); + } + + async readCandidates(agentName: string): Promise { + const path = this.getCandidatesPath(agentName); + const file = this.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return []; + try { + const parsed = JSON.parse(await this.vault.cachedRead(file)) as unknown; + return Array.isArray(parsed) ? (parsed as SkillCandidate[]) : []; + } catch (err) { + console.warn(`Agent Fleet: invalid JSON in ${path} — treating skill-candidate ledger as empty`, err); + return []; + } + } + + async writeCandidates(agentName: string, candidates: SkillCandidate[]): Promise { + const path = this.getCandidatesPath(agentName); + await ensureFolder(this.vault, this.getMemoryDir(agentName)); + const content = JSON.stringify(candidates, null, 2); + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) await this.vault.modify(file, content); + else await createFileIfMissing(this.vault, path, content); + } + + /** Append timestamped lines to the day's raw archive (§6.3). Ground truth — + * append-only, never injected. Caller serializes via the MemoryWriter lock. */ + async appendRawMemory(agentName: string, lines: string[], dateIso: string): Promise { + if (lines.length === 0) return; + const path = this.getRawMemoryPath(agentName, dateIso); + await ensureFolder(this.vault, path.replace(/\/[^/]+$/, "")); + const block = lines.map((l) => l.trimEnd()).join("\n"); + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) { + const existing = await this.vault.cachedRead(file); + await this.vault.modify(file, `${existing.trimEnd()}\n${block}\n`); + } else { + await createFileIfMissing(this.vault, path, `${block}\n`); + } + } + + /** + * @deprecated Back-compat shim. Returns a flat {@link MemoryFile} view, + * preferring the v2 working memory (rendered body) and falling back to the + * legacy flat file. New code should use {@link readWorkingMemory}. + */ + async getMemory(agentName: string): Promise { + const wm = await this.readWorkingMemory(agentName); + if (wm) { + return { + filePath: wm.filePath, + agent: wm.agent, + lastUpdated: wm.lastUpdated, + body: renderSections(wm.sections), + }; + } + return null; + } + + /** + * @deprecated Dead code — no remaining callers. Memory capture now flows + * through MemoryWriter (per-agent FIFO lock) into the v2 layout. This method + * does an UNSERIALIZED read-modify-write of the legacy flat memory file, so + * concurrent calls can lose appends; do not resurrect it without routing it + * through MemoryWriter's lock. + */ + async appendMemory(agentName: string, entries: string[]): Promise { + if (entries.length === 0) { + return; + } + + const path = this.getMemoryPath(agentName); + const file = this.vault.getAbstractFileByPath(path); + const timestamp = new Date().toISOString(); + const entryBlock = entries.map((entry) => `- ${entry.trim()}`).join("\n"); + + if (file instanceof TFile) { + const existing = await this.getMemory(agentName); + const body = `${existing?.body.trim() || "## Learned Context"}\n\n${entryBlock}`.trim(); + await this.vault.modify( + file, + stringifyMarkdownWithFrontmatter( + { + agent: agentName, + last_updated: timestamp, + }, + body, + ), + ); + return; + } + + await createFileIfMissing(this.vault, + path, + stringifyMarkdownWithFrontmatter( + { + agent: agentName, + last_updated: timestamp, + }, + `## Learned Context\n\n${entryBlock}`, + ), + ); + } +} diff --git a/src/repository/proposalStore.test.ts b/src/repository/proposalStore.test.ts new file mode 100644 index 0000000..40157b3 --- /dev/null +++ b/src/repository/proposalStore.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { SkillConfig, SkillProposal } from "../types"; +import { ProposalStore } from "./proposalStore"; +import { FakeVault, makeApp } from "./testSupport"; + +function proposal(overrides: Partial): SkillProposal { + return { + id: "p1", + type: "skill_create", + agent: "bot", + status: "pending", + created: "2026-07-01T00:00:00Z", + evidence: [], + rationale: "recurring pattern", + body: "Do the thing.", + ...overrides, + }; +} + +describe("ProposalStore", () => { + let vault: FakeVault; + let skills: Map; + let store: ProposalStore; + + beforeEach(() => { + vault = new FakeVault(); + skills = new Map(); + store = new ProposalStore(makeApp(vault), { + getFleetRoot: () => "_fleet", + getSkillsDir: () => "_fleet/skills", + getSkillByName: (name) => skills.get(name), + }); + }); + + it("write → read → setStatus round-trips through the proposal file", async () => { + await store.writeProposal(proposal({ id: "p1" })); + expect(vault.files.has("_fleet/proposals/p1.md")).toBe(true); + + const read = await store.readProposal("p1"); + expect(read?.status).toBe("pending"); + expect(read?.agent).toBe("bot"); + + await store.setProposalStatus("p1", "accepted"); + expect((await store.readProposal("p1"))?.status).toBe("accepted"); + }); + + it("applyProposal (skill_create) uses the de-duplicated filename as the skill name", async () => { + vault.addFile("_fleet/skills/my-skill.md", "---\nname: my-skill\n---\n\nExisting\n"); + + const path = await store.applyProposal(proposal({ targetSkill: "My Skill" })); + + expect(path).toBe("_fleet/skills/my-skill-2.md"); + // Frontmatter name matches the de-duplicated file name, not the original. + expect(vault.contents.get(path ?? "")).toContain("name: my-skill-2"); + }); + + it("applyProposal (skill_modify) appends an addendum to the target skill", async () => { + const file = vault.addFile("_fleet/skills/target.md", "---\nname: target\n---\n\nOriginal body\n"); + skills.set("target", { + filePath: file.path, + name: "target", + tags: [], + body: "Original body", + toolsBody: "", + referencesBody: "", + examplesBody: "", + isFolder: false, + }); + + const path = await store.applyProposal( + proposal({ type: "skill_modify", targetSkill: "target", body: "New guidance." }), + ); + + expect(path).toBe("_fleet/skills/target.md"); + const content = vault.contents.get("_fleet/skills/target.md") ?? ""; + expect(content).toContain("Original body"); + expect(content).toContain("## Proposed update"); + expect(content).toContain("New guidance."); + }); + + it("applyProposal (skill_modify) is a no-op when the target skill is missing", async () => { + expect(await store.applyProposal(proposal({ type: "skill_modify", targetSkill: "ghost" }))).toBeNull(); + }); + + it("deleteProposal trashes the file; listProposals sorts newest first", async () => { + await store.writeProposal(proposal({ id: "old", created: "2026-01-01T00:00:00Z" })); + await store.writeProposal(proposal({ id: "new", created: "2026-07-01T00:00:00Z" })); + + const list = await store.listProposals(); + expect(list.map((p) => p.id)).toEqual(["new", "old"]); + + await store.deleteProposal("old"); + expect(await store.readProposal("old")).toBeNull(); + expect((await store.listProposals()).map((p) => p.id)).toEqual(["new"]); + }); +}); diff --git a/src/repository/proposalStore.ts b/src/repository/proposalStore.ts new file mode 100644 index 0000000..891b41f --- /dev/null +++ b/src/repository/proposalStore.ts @@ -0,0 +1,136 @@ +import { TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown"; +import type { SkillConfig, SkillProposal } from "../types"; +import { asString, asStringArray, createFileIfMissing, ensureFolder, getAvailablePath, trashPath } from "./shared"; + +/** + * Skill proposals (memory → skills, §9): `_fleet/proposals/.md` files. + * Extracted verbatim from FleetRepository. `applyProposal` needs the live + * skill index and skills directory, injected as callbacks so the store never + * holds a stale snapshot. + */ +export class ProposalStore { + private readonly vault: Vault; + + constructor( + private readonly app: App, + private readonly deps: { + getFleetRoot: () => string; + getSkillsDir: () => string; + getSkillByName: (name: string) => SkillConfig | undefined; + }, + ) { + this.vault = app.vault; + } + + getProposalsDir(): string { + return normalizePath(`${this.deps.getFleetRoot()}/proposals`); + } + + /** List all proposals, newest first. */ + async listProposals(): Promise { + const folder = this.vault.getAbstractFileByPath(this.getProposalsDir()); + if (!(folder instanceof TFolder)) return []; + const out: SkillProposal[] = []; + for (const child of folder.children) { + if (!(child instanceof TFile) || child.extension !== "md") continue; + const p = await this.readProposal(child.basename); + if (p) out.push(p); + } + out.sort((a, b) => b.created.localeCompare(a.created)); + return out; + } + + async readProposal(id: string): Promise { + const path = normalizePath(`${this.getProposalsDir()}/${id}.md`); + const file = this.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) return null; + const { frontmatter, body } = parseMarkdownWithFrontmatter>( + await this.vault.cachedRead(file), + ); + const type = frontmatter.type === "skill_modify" ? "skill_modify" : "skill_create"; + const status = + frontmatter.status === "accepted" || frontmatter.status === "rejected" + ? frontmatter.status + : "pending"; + return { + id, + type, + agent: asString(frontmatter.agent) ?? "", + status, + created: asString(frontmatter.created) ?? "", + targetSkill: asString(frontmatter.target_skill), + candidate: asString(frontmatter.candidate), + evidence: asStringArray(frontmatter.evidence), + rationale: asString(frontmatter.rationale) ?? "", + body, + }; + } + + async writeProposal(p: SkillProposal): Promise { + await ensureFolder(this.vault, this.getProposalsDir()); + const path = normalizePath(`${this.getProposalsDir()}/${p.id}.md`); + const fm: Record = { + id: p.id, + type: p.type, + agent: p.agent, + status: p.status, + created: p.created, + target_skill: p.targetSkill || undefined, + candidate: p.candidate || undefined, + evidence: p.evidence.length ? p.evidence : undefined, + rationale: p.rationale || undefined, + }; + const content = stringifyMarkdownWithFrontmatter(fm, p.body || ""); + const file = this.vault.getAbstractFileByPath(path); + if (file instanceof TFile) await this.vault.modify(file, content); + else await createFileIfMissing(this.vault, path, content); + } + + async setProposalStatus(id: string, status: SkillProposal["status"]): Promise { + const p = await this.readProposal(id); + if (!p) return; + p.status = status; + await this.writeProposal(p); + } + + /** Apply an accepted proposal: create the new skill (skill_create) or append + * the proposed change to the target skill (skill_modify). Returns the path of + * the affected skill, or null on no-op. */ + async applyProposal(p: SkillProposal): Promise { + if (p.type === "skill_create") { + const name = p.targetSkill || `learned-${slugify(p.rationale).slice(0, 24) || "skill"}`; + const path = await getAvailablePath(this.vault, this.deps.getSkillsDir(), slugify(name)); + // Use the actual (de-duplicated) filename as the skill name so two + // proposals for the same pattern can't mint colliding skill names — + // getAvailablePath only de-dupes the file path, not the frontmatter name. + const finalName = path.split("/").pop()?.replace(/\.md$/, "") || slugify(name); + const fm = { + name: finalName, + description: p.rationale || `Auto-proposed from recurring pattern for ${p.agent}.`, + tags: ["proposed"], + }; + await this.vault.create(path, stringifyMarkdownWithFrontmatter(fm, p.body || "Skill instructions go here.")); + return path; + } + // skill_modify: append the proposed change to the target skill as an addendum. + if (p.targetSkill) { + const skill = this.deps.getSkillByName(p.targetSkill); + if (skill) { + const file = this.vault.getAbstractFileByPath(skill.filePath); + if (file instanceof TFile) { + const existing = await this.vault.cachedRead(file); + const addendum = `\n\n## Proposed update (${new Date().toISOString().slice(0, 10)})\n${p.body}`; + await this.vault.modify(file, `${existing.trimEnd()}${addendum}\n`); + return skill.filePath; + } + } + } + return null; + } + + async deleteProposal(id: string): Promise { + await trashPath(this.app, normalizePath(`${this.getProposalsDir()}/${id}.md`)); + } +} diff --git a/src/repository/runLogStore.ts b/src/repository/runLogStore.ts new file mode 100644 index 0000000..a2fb069 --- /dev/null +++ b/src/repository/runLogStore.ts @@ -0,0 +1,245 @@ +import { TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import { DEFAULT_SETTINGS } from "../constants"; +import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown"; +import { splitLines } from "../utils/platform"; +import type { RunLogData } from "../types"; +import { asNumber, asString, asStringArray, collectMarkdownChildren, ensureFolder, isRecord } from "./shared"; + +/** + * Run-log store: reads/writes `_fleet/runs/YYYY-MM-DD/*.md` files and owns + * the parsed-run cache. Extracted verbatim from FleetRepository — the facade + * delegates its public run-log methods here. + */ +export class RunLogStore { + /** Parsed run-log cache keyed by file path, invalidated on mtime/size + * mismatch. `vault.cachedRead` already avoids disk I/O, but re-parsing + * every run markdown on each dashboard refresh was still O(runs). Cached + * objects are shared — callers must not mutate them (none do today). */ + private runLogCache = new Map(); + + private readonly vault: Vault; + + constructor( + app: App, + private readonly getRunsRoot: () => string, + ) { + this.vault = app.vault; + } + + async listRecentRuns(limit = 50): Promise { + const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot()); + if (!(runsFolder instanceof TFolder)) { + return []; + } + + const files: TFile[] = []; + collectMarkdownChildren(runsFolder, files); + + files.sort((a, b) => b.path.localeCompare(a.path)); + const selected = files.slice(0, limit); + const parsed: RunLogData[] = []; + + for (const file of selected) { + const run = await this.readRunLog(file); + if (run) { + parsed.push(run); + } + } + + return parsed.sort((a, b) => b.started.localeCompare(a.started)); + } + + /** Return all runs whose date folder is on or after `sinceDate`. Used by + * the dashboard chart so a busy fleet can't push older days out of the + * visible window the way the count-capped `listRecentRuns` does. + * Walks only the relevant date subfolders, so cost is bounded by the + * window size, not the total number of historical runs. */ + async listRunsSince(sinceDate: Date): Promise { + const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot()); + if (!(runsFolder instanceof TFolder)) { + return []; + } + + const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`; + const files: TFile[] = []; + for (const child of runsFolder.children) { + if (!(child instanceof TFolder)) continue; + // Date folders are named `YYYY-MM-DD`; lexicographic >= matches calendar >=. + if (child.name < sinceStr) continue; + collectMarkdownChildren(child, files); + } + + const parsed: RunLogData[] = []; + for (const file of files) { + const run = await this.readRunLog(file); + if (run) { + parsed.push(run); + } + } + + return parsed.sort((a, b) => b.started.localeCompare(a.started)); + } + + async readRunLog(file: TFile): Promise { + const cached = this.runLogCache.get(file.path); + if (cached && cached.mtime === file.stat.mtime && cached.size === file.stat.size) { + return cached.run; + } + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + // `## Prompt` terminates at either `## Result` (new format) or + // `## Output` (legacy format). `## Result` is optional and missing on + // runs created before finalResult was extracted — those runs fall back + // to rendering `output` in the panel. + const promptMatch = body.match(/## Prompt\n([\s\S]*?)(?:\n## Result\n|\n## Output\n|$)/); + const resultMatch = body.match(/## Result\n([\s\S]*?)(?:\n## Output\n|$)/); + const outputMatch = body.match(/## Output\n([\s\S]*?)(?:\n## Tools Used\n|$)/); + const toolsMatch = body.match(/## Tools Used\n([\s\S]*?)(?:\n## STDERR\n|$)/); + const run: RunLogData = { + filePath: file.path, + runId: asString(frontmatter.run_id) ?? file.basename, + agent: asString(frontmatter.agent) ?? "unknown", + task: asString(frontmatter.task) ?? "unknown", + status: (asString(frontmatter.status) as RunLogData["status"]) ?? "failure", + started: asString(frontmatter.started) ?? new Date(file.stat.ctime).toISOString(), + completed: asString(frontmatter.completed), + durationSeconds: asNumber(frontmatter.duration_seconds, 0), + tokensUsed: typeof frontmatter.tokens_used === "number" ? frontmatter.tokens_used : undefined, + costUsd: typeof frontmatter.cost_usd === "number" ? frontmatter.cost_usd : undefined, + model: asString(frontmatter.model) ?? DEFAULT_SETTINGS.defaultModel, + modelSource: ((): RunLogData["modelSource"] => { + const raw = asString(frontmatter.model_source); + if (raw === "task" || raw === "agent" || raw === "settings" || raw === "cli-default") return raw; + return undefined; + })(), + concreteModel: asString(frontmatter.resolved_concrete_model), + exitCode: typeof frontmatter.exit_code === "number" ? frontmatter.exit_code : null, + tags: asStringArray(frontmatter.tags), + prompt: promptMatch?.[1]?.trim() ?? "", + output: outputMatch?.[1]?.trim() ?? "", + finalResult: resultMatch?.[1]?.trim() || undefined, + toolsUsed: toolsMatch?.[1] + ? splitLines(toolsMatch[1]) + .map((line) => line.replace(/^- /, "").trim()) + .filter(Boolean) + : [], + approvals: this.parseApprovals(frontmatter.approvals), + }; + // Cheap unbounded-growth guard; a full reset just costs one re-parse pass. + if (this.runLogCache.size >= 5000) this.runLogCache.clear(); + this.runLogCache.set(file.path, { mtime: file.stat.mtime, size: file.stat.size, run }); + return run; + } + + async writeRunLog(run: RunLogData): Promise { + const started = new Date(run.started); + const dateFolder = normalizePath(`${this.getRunsRoot()}/${started.toISOString().slice(0, 10)}`); + await ensureFolder(this.vault, dateFolder); + const filename = `${started.toISOString().slice(11, 19).replace(/:/g, "")}-${slugify(run.agent)}-${slugify(run.task)}.md`; + const path = normalizePath(`${dateFolder}/${filename}`); + const content = stringifyMarkdownWithFrontmatter( + { + run_id: run.runId, + agent: run.agent, + task: run.task, + status: run.status, + started: run.started, + completed: run.completed, + duration_seconds: run.durationSeconds, + tokens_used: run.tokensUsed, + cost_usd: run.costUsd, + model: run.model, + model_source: run.modelSource, + resolved_concrete_model: run.concreteModel, + exit_code: run.exitCode, + tags: run.tags, + approvals: run.approvals, + }, + [ + "## Prompt", + "", + run.prompt.trim(), + "", + // `## Result` carries the final answer without narration, from the + // CLI's `type: "result"` event. Omitted when absent so legacy-format + // run files stay identical. + ...(run.finalResult && run.finalResult.trim() + ? ["## Result", "", run.finalResult.trim(), ""] + : []), + "## Output", + "", + run.output.trim() || "(no output)", + "", + "## Tools Used", + "", + ...(run.toolsUsed.length > 0 ? run.toolsUsed.map((tool) => `- ${tool}`) : ["- none"]), + ...(run.stderr ? ["", "## STDERR", "", run.stderr.trim()] : []), + ].join("\n"), + ); + + const existing = this.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.vault.modify(existing, content); + } else { + await this.vault.create(path, content); + } + return path; + } + + async setApprovalDecision(runPath: string, tool: string, decision: "approved" | "rejected"): Promise { + const file = this.vault.getAbstractFileByPath(runPath); + if (!(file instanceof TFile)) { + return; + } + + const content = await this.vault.cachedRead(file); + const { frontmatter, body } = parseMarkdownWithFrontmatter>(content); + const approvals = (this.parseApprovals(frontmatter.approvals) ?? []).map((approval) => + approval.tool === tool + ? { + ...approval, + status: decision, + resolvedAt: new Date().toISOString(), + } + : approval, + ); + + await this.vault.modify( + file, + stringifyMarkdownWithFrontmatter( + { + ...frontmatter, + approvals, + }, + body, + ), + ); + } + + private parseApprovals(value: unknown): RunLogData["approvals"] { + if (!Array.isArray(value)) { + return undefined; + } + + return value.flatMap((item) => { + if (!isRecord(item) || !asString(item.tool)) { + return []; + } + const tool = asString(item.tool); + if (!tool) { + return []; + } + return [ + { + tool, + command: asString(item.command), + reason: asString(item.reason), + status: (asString(item.status) as "pending" | "approved" | "rejected") ?? "pending", + resolvedAt: asString(item.resolvedAt), + note: asString(item.note), + }, + ]; + }); + } +} diff --git a/src/repository/shared.ts b/src/repository/shared.ts new file mode 100644 index 0000000..bbed0c5 --- /dev/null +++ b/src/repository/shared.ts @@ -0,0 +1,108 @@ +import { TFile, TFolder, normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; + +// ═══════════════════════════════════════════════════════ +// Frontmatter coercion helpers — shared by the FleetRepository facade's +// entity parsers and the extracted stores. All are tolerant: bad shapes +// coerce to a safe fallback instead of throwing. +// ═══════════════════════════════════════════════════════ + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +export function asNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +export function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +/** Coerce a frontmatter value into a `Record`, dropping any + * non-string values. Returns undefined when there's nothing usable so callers + * can omit the field entirely. */ +export function asStringMap(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (typeof v === "string") out[k] = v; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +// ═══════════════════════════════════════════════════════ +// Vault helpers — tolerant create/trash/list primitives shared by the +// facade and the stores. +// ═══════════════════════════════════════════════════════ + +/** Create a folder if it doesn't exist; tolerate a concurrent create. */ +export async function ensureFolder(vault: Vault, path: string): Promise { + if (vault.getAbstractFileByPath(path)) { + return; + } + try { + await vault.createFolder(path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Folder already exists")) { + throw error; + } + } +} + +/** Create a file only when absent; tolerate a concurrent create. */ +export async function createFileIfMissing(vault: Vault, path: string, content: string): Promise { + if (vault.getAbstractFileByPath(path)) { + return; + } + try { + await vault.create(path, content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("File already exists")) { + throw error; + } + } +} + +/** Trash the file/folder at `path` if it exists (no-op otherwise). */ +export async function trashPath(app: App, path: string): Promise { + const file = app.vault.getAbstractFileByPath(path); + if (file) { + await app.fileManager.trashFile(file); + } +} + +/** Recursively collect every markdown TFile under `folder` into `acc`. */ +export function collectMarkdownChildren(folder: TFolder, acc: TFile[]): void { + for (const child of folder.children) { + if (child instanceof TFile && child.extension === "md") { + acc.push(child); + } + if (child instanceof TFolder) { + collectMarkdownChildren(child, acc); + } + } +} + +/** First free `/[-N].md` path (N starts at 2). */ +export async function getAvailablePath(vault: Vault, folder: string, baseName: string): Promise { + let attempt = 0; + while (true) { + const suffix = attempt === 0 ? "" : `-${attempt + 1}`; + const candidate = normalizePath(`${folder}/${baseName}${suffix}.md`); + if (!vault.getAbstractFileByPath(candidate)) { + return candidate; + } + attempt += 1; + } +} diff --git a/src/repository/testSupport.ts b/src/repository/testSupport.ts new file mode 100644 index 0000000..71f28ea --- /dev/null +++ b/src/repository/testSupport.ts @@ -0,0 +1,109 @@ +/** + * Test-only fakes for the repository store tests. NOT imported by production + * code (esbuild never bundles it). Mirrors the FakeVault pattern used in + * src/fleetRepository.test.ts against the `test-support/obsidian.ts` stub. + */ +import type { App } from "obsidian"; +import { TFile, TFolder } from "obsidian"; + +/** Minimal in-memory vault: enough surface for the store paths under test. */ +export class FakeVault { + files = new Map(); + contents = new Map(); + folders = new Map(); + private clock = 1; + + addFile(path: string, content: string): TFile { + const existing = this.files.get(path); + if (existing) { + this.contents.set(path, content); + existing.stat.mtime = this.clock++; + existing.stat.size = content.length; + return existing; + } + const file = new TFile(); + file.path = path; + const base = path.split("/").pop() ?? ""; + file.basename = base.replace(/\.[^.]+$/, ""); + file.extension = base.split(".").pop() ?? ""; + file.stat = { ctime: this.clock, mtime: this.clock++, size: content.length }; + this.files.set(path, file); + this.contents.set(path, content); + this.ensureFolderChain(path.slice(0, path.lastIndexOf("/"))); + return file; + } + + private ensureFolderChain(path: string): void { + if (!path || this.folders.has(path)) return; + const folder = new TFolder(); + folder.path = path; + this.folders.set(path, folder); + const parent = path.slice(0, path.lastIndexOf("/")); + if (parent) this.ensureFolderChain(parent); + } + + getAbstractFileByPath(path: string): TFile | TFolder | null { + const file = this.files.get(path); + if (file) return file; + const folder = this.folders.get(path); + if (!folder) return null; + const children: Array = []; + for (const candidate of [...this.folders.values(), ...this.files.values()]) { + if (!candidate.path.startsWith(`${path}/`)) continue; + if (candidate.path.slice(path.length + 1).includes("/")) continue; + children.push(candidate); + } + folder.children = children; + return folder; + } + + async cachedRead(file: TFile): Promise { + const content = this.contents.get(file.path); + if (content === undefined) throw new Error(`no such file: ${file.path}`); + return content; + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error("File already exists"); + return this.addFile(path, content); + } + + async createFolder(path: string): Promise { + if (this.folders.has(path)) throw new Error("Folder already exists"); + this.ensureFolderChain(path); + } + + async modify(file: TFile, content: string): Promise { + this.addFile(file.path, content); + } + + getMarkdownFiles(): TFile[] { + return [...this.files.values()].filter((f) => f.extension === "md"); + } + + removeTree(path: string): void { + this.files.delete(path); + this.contents.delete(path); + this.folders.delete(path); + for (const p of [...this.files.keys()]) { + if (p.startsWith(`${path}/`)) { + this.files.delete(p); + this.contents.delete(p); + } + } + for (const p of [...this.folders.keys()]) { + if (p.startsWith(`${path}/`)) this.folders.delete(p); + } + } +} + +export function makeApp(vault: FakeVault): App { + return { + vault, + fileManager: { + trashFile: async (file: TFile | TFolder) => { + vault.removeTree(file.path); + }, + }, + } as unknown as App; +} diff --git a/src/repository/usageLedger.test.ts b/src/repository/usageLedger.test.ts new file mode 100644 index 0000000..4f01ab8 --- /dev/null +++ b/src/repository/usageLedger.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import type { UsageRecord } from "../types"; +import { UsageLedger } from "./usageLedger"; + +/** Adapter-level fake — the ledger goes through vault.adapter, not TFiles. */ +class FakeAdapterVault { + data = new Map(); + + adapter = { + exists: async (p: string): Promise => + this.data.has(p) || [...this.data.keys()].some((k) => k.startsWith(`${p}/`)), + list: async (p: string) => ({ + files: [...this.data.keys()].filter( + (k) => k.startsWith(`${p}/`) && !k.slice(p.length + 1).includes("/"), + ), + folders: [] as string[], + }), + read: async (p: string): Promise => { + const c = this.data.get(p); + if (c === undefined) throw new Error(`missing: ${p}`); + return c; + }, + write: async (p: string, c: string): Promise => { + this.data.set(p, c); + }, + append: async (p: string, c: string): Promise => { + this.data.set(p, (this.data.get(p) ?? "") + c); + }, + }; + + getAbstractFileByPath(_path: string): null { + return null; + } + + async createFolder(_path: string): Promise { + /* folders are implicit in the adapter fake */ + } +} + +function record(ts: string, agent = "bot"): UsageRecord { + return { ts, agent, source: "chat", tokensUsed: 10 } as unknown as UsageRecord; +} + +describe("UsageLedger", () => { + let vault: FakeAdapterVault; + let ledger: UsageLedger; + + beforeEach(() => { + vault = new FakeAdapterVault(); + ledger = new UsageLedger({ vault } as unknown as App, () => "_fleet/usage"); + }); + + it("appends one JSONL line per record into the day's file", async () => { + await ledger.appendUsage(record("2026-07-01T10:00:00Z")); + await ledger.appendUsage(record("2026-07-01T11:00:00Z")); + await ledger.appendUsage(record("2026-07-02T09:00:00Z")); + + const day1 = vault.data.get("_fleet/usage/2026-07-01.jsonl") ?? ""; + expect(day1.trim().split("\n")).toHaveLength(2); + expect(vault.data.has("_fleet/usage/2026-07-02.jsonl")).toBe(true); + }); + + it("readUsageSince filters by ledger file date and skips corrupt lines", async () => { + vault.data.set("_fleet/usage/2026-06-01.jsonl", `${JSON.stringify(record("2026-06-01T00:00:00Z"))}\n`); + vault.data.set( + "_fleet/usage/2026-07-01.jsonl", + `${JSON.stringify(record("2026-07-01T00:00:00Z"))}\n{corrupt\n${JSON.stringify(record("2026-07-01T01:00:00Z"))}\n`, + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const out = await ledger.readUsageSince(new Date("2026-06-15T00:00:00Z")); + + expect(out).toHaveLength(2); // June file excluded, corrupt line skipped + expect(out.every((r) => r.ts.startsWith("2026-07-01"))).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("migrateUsageLedgerCosts runs once and is guarded by the marker file", async () => { + vault.data.set("_fleet/usage/2026-07-01.jsonl", `${JSON.stringify(record("2026-07-01T00:00:00Z"))}\n`); + + const first = await ledger.migrateUsageLedgerCosts(); + expect(first).not.toBeNull(); + expect(first?.rows).toBe(1); + expect(vault.data.has("_fleet/usage/.cost-delta-v1")).toBe(true); + + const second = await ledger.migrateUsageLedgerCosts(); + expect(second).toBeNull(); // marker guard + }); + + it("migrateUsageLedgerCosts is a no-op when the usage dir doesn't exist", async () => { + expect(await ledger.migrateUsageLedgerCosts()).toBeNull(); + expect(vault.data.size).toBe(0); + }); +}); diff --git a/src/repository/usageLedger.ts b/src/repository/usageLedger.ts new file mode 100644 index 0000000..0d4db2a --- /dev/null +++ b/src/repository/usageLedger.ts @@ -0,0 +1,132 @@ +import { normalizePath } from "obsidian"; +import type { App, Vault } from "obsidian"; +import { deltaizeCumulativeCosts } from "../utils/usageMigration"; +import type { UsageRecord } from "../types"; +import { ensureFolder } from "./shared"; + +/** + * Usage ledger: append-only chat/channel token+cost records in + * `_fleet/usage/YYYY-MM-DD.jsonl`, plus the one-time cumulative-cost repair + * migration. Extracted verbatim from FleetRepository. + */ +export class UsageLedger { + private readonly vault: Vault; + + constructor( + app: App, + private readonly getUsageDir: () => string, + ) { + this.vault = app.vault; + } + + private usageLedgerPath(ts: string): string { + return normalizePath(`${this.getUsageDir()}/${ts.slice(0, 10)}.jsonl`); + } + + /** Append one usage record to the day's JSONL ledger (one line per turn). + * Uses the raw adapter so it doesn't go through the markdown pipeline. */ + async appendUsage(record: UsageRecord): Promise { + await ensureFolder(this.vault, this.getUsageDir()); + const path = this.usageLedgerPath(record.ts); + const line = `${JSON.stringify(record)}\n`; + const adapter = this.vault.adapter; + if (await adapter.exists(path)) { + await adapter.append(path, line); + } else { + await adapter.write(path, line); + } + } + + /** Read all usage records on or after `sinceDate` (by ledger file date). */ + async readUsageSince(sinceDate: Date): Promise { + const dir = this.getUsageDir(); + const adapter = this.vault.adapter; + if (!(await adapter.exists(dir))) return []; + const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`; + const out: UsageRecord[] = []; + const listing = await adapter.list(dir); + for (const filePath of listing.files) { + if (!filePath.endsWith(".jsonl")) continue; + const base = (filePath.split("/").pop() ?? "").replace(/\.jsonl$/, ""); + // Files are named YYYY-MM-DD; lexicographic >= matches calendar >=. + if (base < sinceStr) continue; + let content: string; + try { + content = await adapter.read(filePath); + } catch { + continue; + } + for (const raw of content.split("\n")) { + const trimmed = raw.trim(); + if (!trimmed) continue; + try { + out.push(JSON.parse(trimmed) as UsageRecord); + } catch (err) { + // skip a corrupt line rather than failing the whole read + console.warn(`Agent Fleet: skipping corrupt usage record in ${filePath}`, err); + } + } + } + return out; + } + + /** + * One-time repair of historical usage rows that stored Claude's CUMULATIVE + * `total_cost_usd` as a per-turn cost (see `deltaizeCumulativeCosts`). Reads + * every `*.jsonl` ledger file, reconstructs per-turn costs per agent across + * the whole ledger (a process can't be assumed to stay within one day), and + * rewrites each file in place. Guarded by a marker file so it runs at most + * once; idempotent and fail-soft (any error leaves the ledger untouched). + */ + async migrateUsageLedgerCosts(): Promise<{ files: number; rows: number; changed: number } | null> { + const dir = this.getUsageDir(); + const adapter = this.vault.adapter; + if (!(await adapter.exists(dir))) return null; + const marker = normalizePath(`${dir}/.cost-delta-v1`); + if (await adapter.exists(marker)) return null; + + try { + const listing = await adapter.list(dir); + const files: Array<{ path: string; records: UsageRecord[] }> = []; + const all: UsageRecord[] = []; + for (const filePath of listing.files) { + if (!filePath.endsWith(".jsonl")) continue; + let content: string; + try { + content = await adapter.read(filePath); + } catch { + continue; + } + const records: UsageRecord[] = []; + for (const raw of content.split("\n")) { + const trimmed = raw.trim(); + if (!trimmed) continue; + try { + records.push(JSON.parse(trimmed) as UsageRecord); + } catch (err) { + // skip a corrupt line rather than failing the whole migration + console.warn(`Agent Fleet: skipping corrupt usage record in ${filePath} during cost migration`, err); + } + } + files.push({ path: filePath, records }); + all.push(...records); + } + + // Mutates `costUsd` on the same objects held in `files[].records`. + const changed = deltaizeCumulativeCosts(all); + + if (changed > 0) { + for (const { path, records } of files) { + if (records.length === 0) continue; + const body = records.map((r) => JSON.stringify(r)).join("\n") + "\n"; + await adapter.write(path, body); + } + } + await adapter.write(marker, `migrated ${all.length} rows; corrected ${changed}\n`); + return { files: files.length, rows: all.length, changed }; + } catch (err) { + console.error("Agent Fleet: usage-ledger cost migration failed (ledger left untouched)", err); + return null; + } + } +} diff --git a/src/services/channelManager.ts b/src/services/channelManager.ts index 9d42fb3..ed18107 100644 --- a/src/services/channelManager.ts +++ b/src/services/channelManager.ts @@ -224,6 +224,22 @@ export class ChannelManager { await Promise.allSettled(Array.from(this.conversationLocks.values())); } + // Drain in-flight turns too — they run OUTSIDE the conversation locks + // (chained via turnTails), so a mid-stream turn would otherwise race the + // hibernation below. Race against a timeout so a stuck turn can't hang + // shutdown forever. + if (this.turnTails.size > 0) { + let timer: number | null = null; + const timeout = new Promise((resolve) => { + timer = window.setTimeout(resolve, 10_000); + }); + await Promise.race([ + Promise.allSettled(Array.from(this.turnTails.values())).then(() => undefined), + timeout, + ]); + if (timer !== null) window.clearTimeout(timer); + } + // Hibernate all sessions (no pending turns — if there are, swallow the rejection). for (const entry of this.sessions.values()) { try { @@ -440,7 +456,10 @@ export class ChannelManager { "_Rate limit exceeded. Please slow down and try again in a few minutes._", ); } catch (err) { - console.warn(`Agent Fleet: rate-limit reply failed on ${channel.name}`, err); + console.warn( + `Agent Fleet: rate-limit reply failed on ${channel.name} (user ${msg.externalUserId}, conversation ${msg.conversationId})`, + err, + ); } return; } diff --git a/src/services/channels/backoff.ts b/src/services/channels/backoff.ts new file mode 100644 index 0000000..c6b5259 --- /dev/null +++ b/src/services/channels/backoff.ts @@ -0,0 +1,27 @@ +/** + * Tiny exponential backoff shared by the channel adapters' reconnect/poll loops. + * Delay sequence: base, base*2, base*4, … capped at `cap`; `reset()` returns to + * base (adapters call it on a successful connection / poll). + */ +export class ExponentialBackoff { + private readonly base: number; + private readonly cap: number; + private delayMs: number; + + constructor(base = 1000, cap = 30_000) { + this.base = base; + this.cap = cap; + this.delayMs = base; + } + + /** Return the current delay and advance to the next (doubled, capped) one. */ + nextDelay(): number { + const delay = this.delayMs; + this.delayMs = Math.min(this.cap, this.delayMs * 2); + return delay; + } + + reset(): void { + this.delayMs = this.base; + } +} diff --git a/src/services/channels/discordAdapter.ts b/src/services/channels/discordAdapter.ts index c303086..89dc707 100644 --- a/src/services/channels/discordAdapter.ts +++ b/src/services/channels/discordAdapter.ts @@ -13,6 +13,8 @@ import type { InboundMessage, StatusHandler, } from "./adapter"; +import { splitText } from "./formatter"; +import { ExponentialBackoff } from "./backoff"; /** * Discord adapter using the Gateway (WebSocket) for inbound events and the REST @@ -79,7 +81,7 @@ export class DiscordAdapter implements ChannelAdapter { private ws: WebSocket | null = null; private status: ChannelStatus = "stopped"; private stopping = false; - private backoffMs = 1000; + private readonly backoff = new ExponentialBackoff(); private reconnectTimer: number | null = null; // Gateway session state (for RESUME). @@ -124,7 +126,7 @@ export class DiscordAdapter implements ChannelAdapter { async start(): Promise { this.stopping = false; - this.backoffMs = 1000; + this.backoff.reset(); this.canResume = false; await this.connect(); } @@ -411,14 +413,14 @@ export class DiscordAdapter implements ChannelAdapter { this.selfUserId = ready.user?.id ?? null; this.applicationId = ready.application?.id ?? null; this.canResume = true; - this.backoffMs = 1000; + this.backoff.reset(); this.setStatus("connected"); void this.registerAgentsCommand(); return; } if (type === "RESUMED") { - this.backoffMs = 1000; + this.backoff.reset(); this.setStatus("connected"); return; } @@ -485,8 +487,7 @@ export class DiscordAdapter implements ChannelAdapter { private scheduleReconnect(): void { if (this.stopping) return; if (this.reconnectTimer) return; - const delay = this.backoffMs; - this.backoffMs = Math.min(30_000, this.backoffMs * 2); + const delay = this.backoff.nextDelay(); console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${delay}ms`); this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = null; @@ -835,18 +836,3 @@ export function describeDiscordError(rawBody: string | undefined): string { } return raw || "no body"; } - -function splitText(text: string, limit: number): string[] { - if (text.length <= limit) return [text]; - const chunks: string[] = []; - let remaining = text; - while (remaining.length > limit) { - let cutAt = remaining.lastIndexOf("\n\n", limit); - if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit); - if (cutAt < limit / 2) cutAt = limit; - chunks.push(remaining.slice(0, cutAt)); - remaining = remaining.slice(cutAt).replace(/^\n+/, ""); - } - if (remaining) chunks.push(remaining); - return chunks; -} diff --git a/src/services/channels/formatter.ts b/src/services/channels/formatter.ts index 10990e2..72b346d 100644 --- a/src/services/channels/formatter.ts +++ b/src/services/channels/formatter.ts @@ -175,6 +175,28 @@ export function splitForTransport(text: string, limit: number = SLACK_TEXT_LIMIT return chunks; } +/** + * Plain chunker for transports that render standard markdown (Discord, Telegram): + * prefer a paragraph break (`\n\n`), then a line break (`\n`), then a hard split + * at the limit. Unlike `splitForTransport` it is NOT fence-aware — deliberately, + * since these transports tolerate a fence broken across messages and the callers + * relied on this exact cut behavior before extraction. + */ +export function splitText(text: string, limit: number): string[] { + if (text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + let cutAt = remaining.lastIndexOf("\n\n", limit); + if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit); + if (cutAt < limit / 2) cutAt = limit; + chunks.push(remaining.slice(0, cutAt)); + remaining = remaining.slice(cutAt).replace(/^\n+/, ""); + } + if (remaining) chunks.push(remaining); + return chunks; +} + /** Count the number of triple-backtick occurrences in a string. */ function countFences(s: string): number { let n = 0; diff --git a/src/services/channels/slackAdapter.ts b/src/services/channels/slackAdapter.ts index 182e6bb..1226189 100644 --- a/src/services/channels/slackAdapter.ts +++ b/src/services/channels/slackAdapter.ts @@ -13,6 +13,7 @@ import type { StatusHandler, } from "./adapter"; import { markdownToMrkdwn } from "./formatter"; +import { ExponentialBackoff } from "./backoff"; /** * Slack Socket Mode adapter. @@ -108,7 +109,7 @@ export class SlackAdapter implements ChannelAdapter { private ws: WebSocket | null = null; private status: ChannelStatus = "stopped"; private stopping = false; - private backoffMs = 1000; + private readonly backoff = new ExponentialBackoff(); private reconnectTimer: number | null = null; private readonly inboundHandlers = new Set(); @@ -383,7 +384,7 @@ export class SlackAdapter implements ChannelAdapter { // `hello` — initial handshake completed; we're ready to receive events. if (envelope.type === "hello") { - this.backoffMs = 1000; // reset backoff on a successful connection + this.backoff.reset(); // reset backoff on a successful connection this.setStatus("connected"); return; } @@ -656,8 +657,7 @@ export class SlackAdapter implements ChannelAdapter { private scheduleReconnect(): void { if (this.stopping) return; if (this.reconnectTimer) return; - const delay = this.backoffMs; - this.backoffMs = Math.min(30_000, this.backoffMs * 2); + const delay = this.backoff.nextDelay(); console.warn( `Agent Fleet: Slack channel ${this.config.name} scheduling reconnect in ${delay}ms`, ); @@ -749,10 +749,15 @@ export class SlackAdapter implements ChannelAdapter { console.warn(`Agent Fleet: Slack send queue error for ${channel}`, err); }); this.sendQueues.set(channel, wrapped); - await next; - // GC: if no subsequent send chained onto this channel, the queue is idle - if (this.sendQueues.get(channel) === wrapped) { - this.sendQueues.delete(channel); + try { + await next; + } finally { + // GC: if no subsequent send chained onto this channel, the queue is idle. + // Runs even when the send throws so a failed channel doesn't leave a + // settled promise behind in sendQueues. + if (this.sendQueues.get(channel) === wrapped) { + this.sendQueues.delete(channel); + } } } } diff --git a/src/services/channels/telegramAdapter.ts b/src/services/channels/telegramAdapter.ts index b85600b..41bbdac 100644 --- a/src/services/channels/telegramAdapter.ts +++ b/src/services/channels/telegramAdapter.ts @@ -12,6 +12,8 @@ import type { InboundMessage, StatusHandler, } from "./adapter"; +import { splitText } from "./formatter"; +import { ExponentialBackoff } from "./backoff"; /** * Telegram Bot API adapter using long-polling (getUpdates). @@ -39,7 +41,7 @@ export class TelegramAdapter implements ChannelAdapter { private stopping = false; private pollOffset = 0; private pollTimer: number | null = null; - private backoffMs = 1000; + private readonly backoff = new ExponentialBackoff(); private typingIntervals = new Map(); /** AbortController for the current long-poll request — lets stop() cancel a 30s wait. */ @@ -268,7 +270,7 @@ export class TelegramAdapter implements ChannelAdapter { } } - this.backoffMs = 1000; // Reset on success + this.backoff.reset(); // Reset on success if (this.status !== "connected") this.setStatus("connected"); } catch (err) { // Abort errors from stop() are expected — don't log or backoff @@ -278,9 +280,8 @@ export class TelegramAdapter implements ChannelAdapter { const msg = err instanceof Error ? err.message : String(err); this.setStatus(msg.includes("401") || msg.includes("Unauthorized") ? "needs-auth" : "error"); } - // Backoff - await new Promise((r) => window.setTimeout(r, this.backoffMs)); - this.backoffMs = Math.min(30_000, this.backoffMs * 2); + // Backoff — wait the current delay; the next failure waits double. + await new Promise((r) => window.setTimeout(r, this.backoff.nextDelay())); } // Schedule next poll @@ -594,18 +595,3 @@ function threadIdFromConversationId(conversationId: string): string | undefined if (parts[2] === "topic" && parts[3]) return parts[3]; return undefined; } - -function splitText(text: string, limit: number): string[] { - if (text.length <= limit) return [text]; - const chunks: string[] = []; - let remaining = text; - while (remaining.length > limit) { - let cutAt = remaining.lastIndexOf("\n\n", limit); - if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit); - if (cutAt < limit / 2) cutAt = limit; - chunks.push(remaining.slice(0, cutAt)); - remaining = remaining.slice(cutAt).replace(/^\n+/, ""); - } - if (remaining) chunks.push(remaining); - return chunks; -} diff --git a/src/services/chatSession.test.ts b/src/services/chatSession.test.ts index 0c2c39e..ffa99b4 100644 --- a/src/services/chatSession.test.ts +++ b/src/services/chatSession.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { ChatSession } from "./chatSession"; -import type { AgentConfig, FleetSettings } from "../types"; +import { ExecutionManager } from "./executionManager"; +import type { AgentConfig, FleetSettings, SkillConfig, TaskConfig, WorkingMemory } from "../types"; import type { FleetRepository } from "../fleetRepository"; +import { MEMORY_CAPTURE_INSTRUCTION } from "../utils/memoryFormat"; // ChatSession imports Vault from "obsidian" (type-only, erased at runtime) and // uses TFile/normalizePath which the test stub provides. We don't drive any @@ -171,6 +173,195 @@ describe("ChatSession.buildBasePrompt", () => { }); }); +// ─── Characterization fixtures for the prompt-parity tests below. +// Mirrors the fixtures in executionManager.test.ts (kept separate so the +// two test files don't import each other's registered tests). ─── + +function makeTask(overrides: Partial = {}): TaskConfig { + return { + filePath: "_fleet/tasks/summarize.md", + taskId: "summarize", + agent: "test-agent", + type: "recurring", + priority: "medium", + enabled: true, + created: "2026-01-01", + runCount: 0, + catchUp: false, + tags: [], + body: "Summarize the news.", + ...overrides, + }; +} + +function makeSkill(): SkillConfig { + return { + filePath: "_fleet/skills/research.md", + name: "research", + tags: [], + body: "Research things thoroughly.", + toolsBody: "Use WebSearch.", + referencesBody: "See RESEARCH.md.", + examplesBody: "Example: find competitors.", + isFolder: false, + }; +} + +function makeWorkingMemory(): WorkingMemory { + return { + filePath: "_fleet/memory/test-agent.md", + agent: "test-agent", + schema: 2, + tokenEstimate: 0, + sections: [ + { name: "Preferences", entries: [{ text: "Prefers concise answers", pinned: true }] }, + { + name: "Recent", + entries: [{ text: "Deploy uses GitHub Actions", pinned: false, source: "run", date: "2026-06-30" }], + }, + ], + }; +} + +function makeKeeperAgent(): AgentConfig { + return makeAgent({ + name: "wiki-keeper-acme", + filePath: "_fleet/agents/wiki-keeper-acme.md", + wikiKeeper: { + scopeRoot: "Acme", + inboxPath: "wiki/inbox", + archivePath: "wiki/archive", + failedPath: "wiki/failed", + topicsRoot: "wiki/topics", + indexPath: "wiki/index.md", + logPath: "wiki/log.md", + watchedFolders: [], + excludePatterns: [], + watchedSince: "", + fileSubstantiveAnswers: false, + obsidianUrlScheme: false, + maxTokensPerIngest: 4000, + maxTokensPerRefresh: 4000, + dedupSimilarityThreshold: 0.8, + summaryStaleDays: 30, + indexSplitThreshold: 50, + stateFile: ".wiki-state.json", + }, + }); +} + +/** The fully-loaded agent used by the byte-exact characterization tests. */ +function makeFullAgent(): AgentConfig { + return makeAgent({ + skills: ["research"], + skillsBody: "Custom agent skill notes.", + contextBody: "Working on Project Apollo.", + memory: true, + wikiReferences: [{ agent: "wiki-keeper-acme" }], + }); +} + +function makeFullRepoStub(): FleetRepository { + const skills = [makeSkill()]; + const agents = [makeKeeperAgent()]; + const wm = makeWorkingMemory(); + return { + getSkillByName: (name: string) => skills.find((s) => s.name === name), + readWorkingMemory: async () => wm, + getAgentByName: (name: string) => agents.find((a) => a.name === name), + } as unknown as FleetRepository; +} + +async function callBuildBasePrompt(session: ChatSession): Promise { + return (session as unknown as { buildBasePrompt(): Promise }).buildBasePrompt(); +} + +// Characterization tests — they capture the CURRENT byte-exact prompt output of +// the chat path (and its parity with the one-shot run path) so the shared +// prompt-assembly extraction is provably behavior-preserving. +describe("ChatSession.buildBasePrompt — characterization / run-path parity", () => { + it("base prompt + '## Task' framing is byte-identical to ExecutionManager.buildPrompt for the same agent", async () => { + const repo = makeFullRepoStub(); + const agent = makeFullAgent(); + + const session = new ChatSession(agent, makeSettings(), repo, vaultStub); + const basePrompt = await callBuildBasePrompt(session); + // sendMessage frames the first turn as `${basePrompt}\n\n## Task\n${messageText}` + const chatFirstTurn = `${basePrompt}\n\n## Task\nSummarize the news.`; + + const manager = new ExecutionManager(makeSettings(), repo); + const runPrompt = await manager.buildPrompt(agent, makeTask({ body: "Summarize the news." })); + + expect(chatFirstTurn).toBe(runPrompt); + }); + + it("fully-loaded agent with channel context: byte-exact section order (memory → channel → wiki)", async () => { + const repo = makeFullRepoStub(); + const session = new ChatSession(makeFullAgent(), makeSettings(), repo, vaultStub, { + channelName: "my-slack", + conversationId: "slack:T1:C1:U1", + channelContext: "You are being contacted via Slack.", + }); + const prompt = await callBuildBasePrompt(session); + + // The channel-context section sits between memory and wiki access — + // this exact ordering is load-bearing (captured pre-refactor). + const memorySection = + `## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` + + "## Preferences\n- [pin] Prefers concise answers\n\n" + + "## Recent (uncurated)\n- Deploy uses GitHub Actions "; + + expect(prompt).toContain( + `${memorySection}\n\n## Channel Context\nYou are being contacted via Slack.\n\n## Wiki Access\n`, + ); + expect(prompt.startsWith("You are a helpful test agent.\n\n## Skill: research\n")).toBe(true); + }); + + it("thread mode section comes after wiki access (last section)", async () => { + const repo = makeFullRepoStub(); + const agent = makeFullAgent(); + const parent = new ChatSession(agent, makeSettings(), repo, vaultStub); + parent.messages = [ + { id: "m0", role: "user", content: "hi", timestamp: "t0" }, + { id: "m1", role: "assistant", content: "hello there", timestamp: "t1" }, + ]; + const thread = new ChatSession(agent, makeSettings(), repo, vaultStub, { + threadAnchorId: "m1", + parentSession: parent, + }); + (thread as unknown as { threadAnchorIndex: number }).threadAnchorIndex = 1; + + const prompt = await callBuildBasePrompt(thread); + + const wikiIdx = prompt.indexOf("## Wiki Access"); + const threadIdx = prompt.indexOf("## Thread Mode"); + expect(wikiIdx).toBeGreaterThan(-1); + expect(threadIdx).toBeGreaterThan(wikiIdx); + // Replay content is exact: preamble, then a "## Conversation so far" replay. + expect(prompt.endsWith( + "## Thread Mode\n" + + "You are continuing a side thread from this conversation. The user is " + + "following up on one of your earlier replies and wants to explore " + + "something specific without adding to the main thread. Your answers " + + "here stay in this thread only and will NOT be added back to the " + + "main conversation.\n\n" + + "## Conversation so far\nUser: hi\nAssistant: hello there", + )).toBe(true); + }); + + it("memory-enabled chat agent gets the memory section unconditionally (no chat-side suppression)", async () => { + const repo = makeFullRepoStub(); + const session = new ChatSession(makeAgent({ memory: true }), makeSettings(), repo, vaultStub); + const prompt = await callBuildBasePrompt(session); + expect(prompt).toBe( + "You are a helpful test agent.\n\n" + + `## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` + + "## Preferences\n- [pin] Prefers concise answers\n\n" + + "## Recent (uncurated)\n- Deploy uses GitHub Actions ", + ); + }); +}); + describe("ChatSession.hibernate / clearSessionId", () => { it("hibernate refuses to run while a turn is streaming", () => { const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub); @@ -450,6 +641,94 @@ describe("ChatSession.detachProcessListeners", () => { }); }); +describe("ChatSession.handleStdout — partial-line buffer cap", () => { + type StdoutInternals = { stdoutBuffer: string; handleStdout(chunk: string): void }; + + it("keeps a small incomplete trailing line buffered", () => { + const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub); + const s = session as unknown as StdoutInternals; + s.handleStdout('{"type":"sys'); + expect(s.stdoutBuffer).toBe('{"type":"sys'); + }); + + it("drops a pathological oversized partial line instead of buffering unboundedly", () => { + const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub); + const s = session as unknown as StdoutInternals; + // One giant chunk with no newline — must not be retained. + s.handleStdout("x".repeat(10 * 1024 * 1024 + 1)); + expect(s.stdoutBuffer).toBe(""); + // Subsequent well-formed lines still parse (session id is captured). + s.handleStdout('{"type":"system","session_id":"s-after-drop"}\n'); + expect( + (session as unknown as { claudeSessionId: string | null }).claudeSessionId, + ).toBe("s-after-drop"); + }); +}); + +describe("ChatSession.handleProcessClose — streaming reset between turns", () => { + it("resets streaming state even when no turn resolve is pending", () => { + const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub); + type Internals = { + pendingTurns: number; + setStreaming(active: boolean): void; + handleProcessClose(): void; + turnResolve: unknown; + }; + const s = session as unknown as Internals; + // Simulate a wedged state: spinner on, no resolver to end the turn. + s.setStreaming(true); + s.pendingTurns = 1; + expect(s.turnResolve).toBeNull(); + + s.handleProcessClose(); + + expect(session.isStreaming).toBe(false); + expect(session.pendingTurnCount).toBe(0); + expect(session.isProcessAlive).toBe(false); + }); +}); + +describe("ChatSession codex queue — failed follow-up start is reported, not silent", () => { + it("emits an error event naming the dropped queued messages when startCodexTurn rejects", async () => { + const session = new ChatSession( + makeAgent({ adapter: "codex" }), + makeSettings(), + makeRepositoryStub(), + vaultStub, + ); + type Internals = { + codexQueue: string[]; + pendingTurns: number; + activeOnEvent: ((ev: { type: string; errorMessage?: string }) => void) | null; + startCodexTurn(text: string): Promise; + handleTurnEnd(): void; + turnReject: ((e: Error) => void) | null; + }; + const s = session as unknown as Internals; + const events: Array<{ type: string; errorMessage?: string }> = []; + const rejections: Error[] = []; + s.activeOnEvent = (ev) => events.push(ev); + s.turnReject = (e) => rejections.push(e); + s.codexQueue = ["queued follow-up", "second follow-up"]; + s.pendingTurns = 3; + s.startCodexTurn = () => Promise.reject(new Error("spawn ENOENT")); + + s.handleTurnEnd(); + // Let the startCodexTurn rejection propagate through the catch handler. + await new Promise((r) => setTimeout(r, 0)); + + const errEvent = events.find((e) => e.type === "error"); + // Both the shifted message and the one still queued are reported. + expect(errEvent?.errorMessage).toMatch(/dropping 2 queued messages/); + expect(errEvent?.errorMessage).toContain("spawn ENOENT"); + // The turn promise still rejects (handleProcessError ran) and the queue + // is cleared — no stale entries linger for the next turn. + expect(rejections.map((e) => e.message)).toEqual(["spawn ENOENT"]); + expect(s.codexQueue).toEqual([]); + expect(session.isStreaming).toBe(false); + }); +}); + describe("ChatSession.refreshAgent — picks up post-construction permission edits", () => { it("swaps in the latest AgentConfig from the repository when invoked", () => { const constructionTime = makeAgent({ diff --git a/src/services/chatSession.ts b/src/services/chatSession.ts index fe02c15..3ef3de6 100644 --- a/src/services/chatSession.ts +++ b/src/services/chatSession.ts @@ -7,10 +7,10 @@ import type { FleetRepository } from "../fleetRepository"; import { slugify } from "../utils/markdown"; import { resolveModel, shouldPassModelFlag } from "./../utils/modelResolution"; import { spawnCli, splitLines } from "../utils/platform"; -import { buildWikiReferencesContext } from "../utils/wikiReferences"; -import { buildMemorySection, extractCaptures, redactRememberForDisplay, stripRememberTags } from "../utils/memoryFormat"; +import { extractCaptures, redactRememberForDisplay, stripRememberTags } from "../utils/memoryFormat"; import { MemoryWriter } from "./memoryWriter"; import type { McpAuthManager } from "./mcpAuth"; +import { buildAgentPromptSections } from "./promptAssembly"; import { installMcpProjection, resolveProjectedServers, @@ -176,6 +176,11 @@ export class ChatSession { private claudeResumeAttempted = false; private vault: Vault; private stdoutBuffer = ""; + /** Cap on the buffered partial stdout line. Stream-json is line-delimited, + * so the buffer only ever holds one incomplete line — a line this size + * means the CLI is emitting something pathological, and buffering it + * further would grow memory unboundedly. */ + private static readonly STDOUT_LINE_CAP = 10 * 1024 * 1024; /** Bound event handlers for the current process — kept so we can removeListener on kill. */ private processListeners: { onStdout: (chunk: Buffer | string) => void; @@ -779,6 +784,15 @@ export class ChatSession { this.stdoutBuffer += chunk.toString(); const lines = splitLines(this.stdoutBuffer); this.stdoutBuffer = lines.pop() ?? ""; // keep incomplete trailing line + if (this.stdoutBuffer.length > ChatSession.STDOUT_LINE_CAP) { + // Drop the oversized partial line. When its tail (and newline) finally + // arrives, the leftover fragment fails JSON.parse and is skipped, so + // line-based parsing of subsequent events continues unharmed. + console.warn( + `Agent Fleet: chat stdout line exceeded ${ChatSession.STDOUT_LINE_CAP} chars — dropping partial line`, + ); + this.stdoutBuffer = ""; + } for (const line of lines) { const trimmed = line.trim(); @@ -1151,6 +1165,18 @@ export class ChatSession { if (next !== undefined) { this.armWatchdog(); void this.startCodexTurn(next).catch((err: unknown) => { + // The dequeued message never reached the CLI and handleProcessError + // clears the rest of the queue — report what was dropped so the + // failure isn't silent. (Re-queuing and retrying here could loop + // forever on a persistent spawn failure, so we drop-and-report.) + const dropped = 1 + this.codexQueue.length; + this.activeOnEvent?.({ + type: "error", + content: "", + errorMessage: + `failed to start queued Codex turn — dropping ${dropped} queued ` + + `message${dropped === 1 ? "" : "s"}: ${err instanceof Error ? err.message : String(err)}`, + }); this.handleProcessError(err instanceof Error ? err : new Error(String(err))); }); return; @@ -1205,14 +1231,16 @@ export class ChatSession { this.mcpProjection = null; // If a turn was pending, resolve with whatever we accumulated - if (this.turnResolve) { + const resolve = this.turnResolve; + let result: { text: string; toolCalls: ToolCall[] } | null = null; + if (resolve) { // Resumed turn that produced nothing → the session is almost certainly // expired/missing. Drop the id so the next turn starts fresh instead of // re-resuming a dead session and staying silent. if (this.claudeResumeAttempted && !this.turnResponseText.trim()) { this.clearSessionId(); } - const result = { text: this.turnResponseText, toolCalls: [...this.turnToolCalls] }; + result = { text: this.turnResponseText, toolCalls: [...this.turnToolCalls] }; if (this.turnResponseText.trim()) { this.messages.push({ @@ -1223,19 +1251,22 @@ export class ChatSession { toolCalls: this.turnToolCalls.length > 0 ? [...this.turnToolCalls] : undefined, }); } + } - this.pendingTurns = 0; - this.turnResponseText = ""; - this.turnToolCalls = []; - this.clearWatchdog(); - this.setStreaming(false); + // Reset streaming state unconditionally — a process closing between turns + // (no pending resolve) must still drop the spinner, otherwise isStreaming + // wedges at true with no process left to end the turn. + this.pendingTurns = 0; + this.turnResponseText = ""; + this.turnToolCalls = []; + this.clearWatchdog(); + this.setStreaming(false); + if (resolve && result) { void this.persist(); - - const resolve = this.turnResolve; this.turnResolve = null; this.turnReject = null; - resolve?.(result); + resolve(result); } } @@ -1447,7 +1478,13 @@ export class ChatSession { proc.stdin!.write(invocation.stdinPayload ?? messageText); proc.stdin!.end(); } catch (err) { + // Detach BEFORE killing so the kill's close event can't fire + // handleCodexProcessClose and double-settle the turn the caller is + // about to fail (it would also leak the listeners otherwise). + this.detachProcessListeners(); try { proc.kill(); } catch { /* ignore */ } + this.process = null; + this.isProcessAlive = false; throw err instanceof Error ? err : new Error(String(err)); } } @@ -1628,42 +1665,15 @@ export class ChatSession { * memory entirely for channel-bound agents. */ private async buildBasePrompt(): Promise { - const sections: string[] = [this.agent.body.trim()]; - - for (const skillName of this.agent.skills) { - const skill = this.repository.getSkillByName(skillName); - if (skill) { - const parts = [skill.body.trim()]; - if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`); - if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`); - if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`); - sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`); - } - } - - if (this.agent.skillsBody.trim()) { - sections.push(`## Agent Skills\n${this.agent.skillsBody.trim()}`); - } - - if (this.agent.contextBody.trim()) { - sections.push(`## Agent Context\n${this.agent.contextBody.trim()}`); - } - - if (this.agent.memory) { - const wm = await this.repository.readWorkingMemory(this.agent.name); - const memorySection = buildMemorySection(this.agent, wm); - if (memorySection) sections.push(memorySection); - } - - // Channel context appended LAST so it takes priority over earlier sections - // without shadowing the agent's identity. - if (this.channelContext && this.channelContext.trim()) { - sections.push(`## Channel Context\n${this.channelContext.trim()}`); - } - - // Wiki references — consumer-mode access to one or more Wiki Keeper scopes. - const wikiContext = buildWikiReferencesContext(this.agent, this.repository); - if (wikiContext) sections.push(wikiContext); + // Common sections (body/skills/context/memory/wiki) come from the shared + // assembly so the chat and one-shot run paths cannot drift. The chat-only + // channel context travels as an option because it sits between memory and + // wiki access; the thread-mode replay below is appended caller-side since + // it is always the final section. + const sections = await buildAgentPromptSections(this.repository, this.agent, { + memoryActive: this.agent.memory, + channelContext: this.channelContext, + }); // Thread mode: append soft-fork preamble + replay parent history up to // and including the anchor message. See CHAT_THREADING_DESIGN.md §4.2. diff --git a/src/services/executionManager.test.ts b/src/services/executionManager.test.ts index 95478bf..5421347 100644 --- a/src/services/executionManager.test.ts +++ b/src/services/executionManager.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { extractConcreteModel, extractRememberEntries } from "./executionManager"; +import { ExecutionManager, extractConcreteModel, extractRememberEntries } from "./executionManager"; +import type { AgentConfig, FleetSettings, SkillConfig, TaskConfig, WorkingMemory } from "../types"; +import type { FleetRepository } from "../fleetRepository"; +import { MEMORY_CAPTURE_INSTRUCTION } from "../utils/memoryFormat"; describe("execution helpers", () => { it("extracts remember directives", () => { @@ -53,3 +56,304 @@ Middle }); }); }); + +// ─── Shared prompt-assembly test fixtures (also used by chatSession.test.ts's +// cross-path parity test) ─── + +function makeAgent(overrides: Partial = {}): AgentConfig { + return { + filePath: "_fleet/agents/test-agent.md", + name: "test-agent", + description: "An agent for testing", + model: "default", + adapter: "claude-code", + permissionMode: "bypassPermissions", + maxRetries: 1, + skills: [], + mcpServers: [], + enabled: true, + timeout: 300, + approvalRequired: [], + memory: false, + memoryMaxEntries: 100, + memoryTokenBudget: 1500, + reflection: { enabled: false, schedule: "0 3 * * *", recurrenceThreshold: 3, proposeSkills: false }, + tags: [], + avatar: "", + body: "You are a helpful test agent.", + contextBody: "", + skillsBody: "", + env: {}, + permissionRules: { allow: [], deny: [] }, + isFolder: false, + heartbeatEnabled: false, + heartbeatSchedule: "", + heartbeatBody: "", + heartbeatNotify: true, + heartbeatChannel: "", + heartbeatChannelTarget: "", + ...overrides, + }; +} + +function makeSettings(overrides: Partial = {}): FleetSettings { + return { + fleetFolder: "_fleet", + claudeCliPath: "claude", + codexCliPath: "codex", + defaultModel: "default", + awsRegion: "us-east-1", + maxConcurrentRuns: 2, + runLogRetentionDays: 30, + catchUpMissedTasks: true, + notificationLevel: "all", + showStatusBar: true, + mcpApiKeys: {}, + mcpTokens: {}, + channelCredentials: {}, + maxConcurrentChannelSessions: 5, + channelIdleTimeoutMinutes: 15, + channelRateLimitPerConversation: 20, + channelRateLimitWindowMinutes: 5, + chatWatchdogMinutes: 10, + defaultFileHashes: {}, + ...overrides, + }; +} + +function makeTask(overrides: Partial = {}): TaskConfig { + return { + filePath: "_fleet/tasks/summarize.md", + taskId: "summarize", + agent: "test-agent", + type: "recurring", + priority: "medium", + enabled: true, + created: "2026-01-01", + runCount: 0, + catchUp: false, + tags: [], + body: "Summarize the news.", + ...overrides, + }; +} + +function makeSkill(overrides: Partial = {}): SkillConfig { + return { + filePath: "_fleet/skills/research.md", + name: "research", + tags: [], + body: "Research things thoroughly.", + toolsBody: "Use WebSearch.", + referencesBody: "See RESEARCH.md.", + examplesBody: "Example: find competitors.", + isFolder: false, + ...overrides, + }; +} + +function makeWorkingMemory(): WorkingMemory { + return { + filePath: "_fleet/memory/test-agent.md", + agent: "test-agent", + schema: 2, + tokenEstimate: 0, + sections: [ + { name: "Preferences", entries: [{ text: "Prefers concise answers", pinned: true }] }, + { + name: "Recent", + entries: [{ text: "Deploy uses GitHub Actions", pinned: false, source: "run", date: "2026-06-30" }], + }, + ], + }; +} + +/** A wiki-keeper agent that `wikiReferences: [{ agent: "wiki-keeper-acme" }]` resolves to. */ +function makeKeeperAgent(): AgentConfig { + return makeAgent({ + name: "wiki-keeper-acme", + filePath: "_fleet/agents/wiki-keeper-acme.md", + wikiKeeper: { + scopeRoot: "Acme", + inboxPath: "wiki/inbox", + archivePath: "wiki/archive", + failedPath: "wiki/failed", + topicsRoot: "wiki/topics", + indexPath: "wiki/index.md", + logPath: "wiki/log.md", + watchedFolders: [], + excludePatterns: [], + watchedSince: "", + fileSubstantiveAnswers: false, + obsidianUrlScheme: false, + maxTokensPerIngest: 4000, + maxTokensPerRefresh: 4000, + dedupSimilarityThreshold: 0.8, + summaryStaleDays: 30, + indexSplitThreshold: 50, + stateFile: ".wiki-state.json", + }, + }); +} + +function makePromptRepoStub(opts: { + skills?: SkillConfig[]; + workingMemory?: WorkingMemory | null; + agents?: AgentConfig[]; +} = {}): FleetRepository { + return { + getSkillByName: (name: string) => opts.skills?.find((s) => s.name === name), + readWorkingMemory: async () => opts.workingMemory ?? null, + getAgentByName: (name: string) => opts.agents?.find((a) => a.name === name), + } as unknown as FleetRepository; +} + +/** The exact `## Memory` block buildPrompt emits for {@link makeWorkingMemory}. */ +const EXPECTED_MEMORY_SECTION = + `## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` + + "## Preferences\n- [pin] Prefers concise answers\n\n" + + "## Recent (uncurated)\n- Deploy uses GitHub Actions "; + +/** The exact `## Wiki Access` block for {@link makeKeeperAgent}. */ +const EXPECTED_WIKI_SECTION = [ + "## Wiki Access", + "You have read access to the following wikis maintained by other agents. " + + "Use the `wiki-query` skill in consumer mode when the user asks something " + + "a wiki may already cover.", + "", + "### Wiki: `wiki-keeper-acme`", + "- scope root: `Acme`", + "- topics: `Acme/wiki/topics/`", + "- index: `Acme/wiki/index.md`", + "- inbox: `Acme/wiki/inbox/`", + "", + "### Rules", + "- **Cite every factual claim** from a wiki using `[[/]]`. " + + "If a claim has no page, say the wiki doesn't cover it — do not fabricate.", + "- **When the user shares something durable** that isn't yet in a wiki " + + "(a decision, a new entity mention, a competitor change, a meeting outcome), " + + "write a short markdown file to the relevant wiki's inbox at " + + "`/YYYY-MM-DD-.md` with a one-line note + the source. " + + "The wiki keeper files it canonically on its next ingest.", + "- **Do NOT write to `/` directly.** The wiki keeper is the " + + "canonical curator of topic pages. Use the inbox as your deposit point.", + "- **When the question spans multiple wikis**, be explicit about which " + + "scope each cited page belongs to.", +].join("\n"); + +const EXPECTED_SKILL_SECTION = + "## Skill: research\nResearch things thoroughly.\n\n" + + "### Tools\nUse WebSearch.\n\n" + + "### References\nSee RESEARCH.md.\n\n" + + "### Examples\nExample: find competitors."; + +/** The fully-loaded agent used by the byte-exact characterization tests. */ +function makeFullAgent(): AgentConfig { + return makeAgent({ + skills: ["research"], + skillsBody: "Custom agent skill notes.", + contextBody: "Working on Project Apollo.", + memory: true, + wikiReferences: [{ agent: "wiki-keeper-acme" }], + }); +} + +function makeFullRepoStub(): FleetRepository { + return makePromptRepoStub({ + skills: [makeSkill()], + workingMemory: makeWorkingMemory(), + agents: [makeKeeperAgent()], + }); +} + +// Characterization tests — these capture the CURRENT byte-exact prompt output +// of the one-shot run path so the shared prompt-assembly extraction is provably +// behavior-preserving. Do not "improve" the expected strings; they are the spec. +describe("ExecutionManager.buildPrompt — characterization", () => { + function makeManager(repo: FleetRepository): ExecutionManager { + return new ExecutionManager(makeSettings(), repo); + } + + it("assembles body + skill + agent skills + context + memory + wiki + task, byte-exact", async () => { + const manager = makeManager(makeFullRepoStub()); + const prompt = await manager.buildPrompt(makeFullAgent(), makeTask()); + + expect(prompt).toBe( + [ + "You are a helpful test agent.", + EXPECTED_SKILL_SECTION, + "## Agent Skills\nCustom agent skill notes.", + "## Agent Context\nWorking on Project Apollo.", + EXPECTED_MEMORY_SECTION, + EXPECTED_WIKI_SECTION, + "## Task\nSummarize the news.", + ].join("\n\n"), + ); + }); + + it("minimal agent: just body + task", async () => { + const manager = makeManager(makePromptRepoStub()); + const prompt = await manager.buildPrompt(makeAgent(), makeTask()); + expect(prompt).toBe("You are a helpful test agent.\n\n## Task\nSummarize the news."); + }); + + it("promptOverride (heartbeat path) replaces the task body and is trimmed", async () => { + const manager = makeManager(makePromptRepoStub()); + const prompt = await manager.buildPrompt( + makeAgent(), + makeTask(), + " Check all site monitors and report anomalies.\n", + ); + expect(prompt).toBe( + "You are a helpful test agent.\n\n## Task\nCheck all site monitors and report anomalies.", + ); + }); + + it("memory enabled but no working-memory file yet → fresh-agent placeholder", async () => { + const manager = makeManager(makePromptRepoStub({ workingMemory: null })); + const prompt = await manager.buildPrompt(makeAgent({ memory: true }), makeTask()); + expect(prompt).toBe( + [ + "You are a helpful test agent.", + `## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\nNothing yet — this is a fresh agent.`, + "## Task\nSummarize the news.", + ].join("\n\n"), + ); + }); + + it("memoryActive=false (reflection suppression) omits the memory section even for a memory agent", async () => { + const manager = makeManager(makePromptRepoStub({ workingMemory: makeWorkingMemory() })); + const prompt = await manager.buildPrompt(makeAgent({ memory: true }), makeTask(), undefined, false); + expect(prompt).toBe("You are a helpful test agent.\n\n## Task\nSummarize the news."); + }); + + it("unknown skill names are silently skipped", async () => { + const manager = makeManager(makePromptRepoStub({ skills: [makeSkill()] })); + const prompt = await manager.buildPrompt( + makeAgent({ skills: ["missing-skill", "research"] }), + makeTask(), + ); + expect(prompt).toBe( + ["You are a helpful test agent.", EXPECTED_SKILL_SECTION, "## Task\nSummarize the news."].join("\n\n"), + ); + }); + + it("skill sub-bodies are optional — empty ones drop their heading", async () => { + const skill = makeSkill({ toolsBody: "", referencesBody: " ", examplesBody: "" }); + const manager = makeManager(makePromptRepoStub({ skills: [skill] })); + const prompt = await manager.buildPrompt(makeAgent({ skills: ["research"] }), makeTask()); + expect(prompt).toBe( + [ + "You are a helpful test agent.", + "## Skill: research\nResearch things thoroughly.", + "## Task\nSummarize the news.", + ].join("\n\n"), + ); + }); + + it("empty agent body is filtered out (no leading blank section)", async () => { + const manager = makeManager(makePromptRepoStub()); + const prompt = await manager.buildPrompt(makeAgent({ body: " " }), makeTask()); + expect(prompt).toBe("## Task\nSummarize the news."); + }); +}); diff --git a/src/services/executionManager.ts b/src/services/executionManager.ts index 1c6cb96..94e1466 100644 --- a/src/services/executionManager.ts +++ b/src/services/executionManager.ts @@ -5,9 +5,8 @@ import type { FleetRepository } from "../fleetRepository"; import { getAdapter } from "../adapters"; import { resolveModel, shouldPassModelFlag } from "../utils/modelResolution"; import { spawnCli, splitLines } from "../utils/platform"; -import { buildWikiReferencesContext } from "../utils/wikiReferences"; -import { buildMemorySection } from "../utils/memoryFormat"; import type { McpAuthManager } from "./mcpAuth"; +import { buildAgentPromptSections } from "./promptAssembly"; import { installMcpProjection, resolveProjectedServers, @@ -18,6 +17,12 @@ import { // Claude Code adapter when execution became adapter-dispatched. export { extractConcreteModel, extractFinalResult } from "../adapters/claudeCodeAdapter"; +// Output caps — a runaway CLI (e.g. a tool looping on huge output) would +// otherwise grow these buffers unbounded until the renderer OOMs. Exceeding +// either cap kills the process and fails the run, mirroring the timeout path. +const MAX_STDOUT_LENGTH = 10 * 1024 * 1024; +const MAX_STDERR_LENGTH = 1024 * 1024; + export function extractRememberEntries(output: string): string[] { const matches = output.matchAll(/\[REMEMBER\]([\s\S]*?)\[\/REMEMBER\]/g); return Array.from(matches) @@ -51,40 +56,10 @@ export class ExecutionManager { promptOverride?: string, memoryActive = agent.memory, ): Promise { - const sections: string[] = [agent.body.trim()]; - - // Shared skills - for (const skillName of agent.skills) { - const skill = this.repository.getSkillByName(skillName); - if (skill) { - const parts = [skill.body.trim()]; - if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`); - if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`); - if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`); - sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`); - } - } - - // Agent-specific skills (from SKILLS.md in folder agents) - if (agent.skillsBody.trim()) { - sections.push(`## Agent Skills\n${agent.skillsBody.trim()}`); - } - - // Agent context (from CONTEXT.md in folder agents) - if (agent.contextBody.trim()) { - sections.push(`## Agent Context\n${agent.contextBody.trim()}`); - } - - if (memoryActive) { - const wm = await this.repository.readWorkingMemory(agent.name); - const memorySection = buildMemorySection(agent, wm); - if (memorySection) sections.push(memorySection); - } - - // Wiki references — consumer-mode access to one or more Wiki Keeper scopes. - const wikiContext = buildWikiReferencesContext(agent, this.repository); - if (wikiContext) sections.push(wikiContext); - + // Common sections (body/skills/context/memory/wiki) come from the shared + // assembly so the run and chat paths cannot drift; only the `## Task` + // framing is run-specific. + const sections = await buildAgentPromptSections(this.repository, agent, { memoryActive }); sections.push(`## Task\n${(promptOverride ?? task.body).trim()}`); return sections.filter(Boolean).join("\n\n"); } @@ -179,6 +154,7 @@ export class ExecutionManager { let stdout = ""; let stderr = ""; let timedOut = false; + let outputLimitError: string | null = null; const timer = window.setTimeout(() => { timedOut = true; @@ -186,8 +162,14 @@ export class ExecutionManager { }, agent.timeout * 1000); proc.stdout!.on("data", (chunk: Buffer | string) => { + if (outputLimitError) return; // already killed — drop buffered chunks const text = chunk.toString(); stdout += text; + if (stdout.length > MAX_STDOUT_LENGTH) { + outputLimitError = `Run output exceeded the ${MAX_STDOUT_LENGTH / (1024 * 1024)}MB stdout limit — process killed.`; + proc.kill(); + return; + } if (useStreaming && onOutput) { // Parse stream lines for displayable content for (const line of splitLines(text)) { @@ -198,7 +180,12 @@ export class ExecutionManager { }); proc.stderr!.on("data", (chunk: Buffer | string) => { + if (outputLimitError) return; stderr += chunk.toString(); + if (stderr.length > MAX_STDERR_LENGTH) { + outputLimitError = `Run stderr exceeded the ${MAX_STDERR_LENGTH / (1024 * 1024)}MB limit — process killed.`; + proc.kill(); + } }); proc.on("error", (error) => { @@ -210,6 +197,11 @@ export class ExecutionManager { window.clearTimeout(timer); this.runningProcesses.delete(agent.name); + if (outputLimitError) { + reject(new Error(outputLimitError)); + return; + } + const parsed = adapter.parseExecOutput(stdout, stderr, useStreaming); resolve({ diff --git a/src/services/fleetRuntime.test.ts b/src/services/fleetRuntime.test.ts new file mode 100644 index 0000000..e558b6e --- /dev/null +++ b/src/services/fleetRuntime.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FleetRepository } from "../fleetRepository"; +import type { RunLogData } from "../types"; +import { DEFAULT_SETTINGS } from "../constants"; +import { FleetRuntime } from "./fleetRuntime"; + +// The obsidian test stub doesn't export Notice; provide one that records +// messages so notify() behavior can be asserted. +const notices = vi.hoisted(() => [] as Array<{ message: string; timeout?: number }>); +vi.mock("obsidian", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + Notice: class { + constructor(message: string, timeout?: number) { + notices.push({ message, timeout }); + } + }, + }; +}); + +/** Private members exercised directly (they have no public trigger that + * doesn't spawn a CLI). */ +interface RuntimeInternals { + refreshRunCaches(): Promise; + emitRunOutput(agentName: string, chunk: string): void; + resetRunOutput(agentName: string): void; + clearRunOutput(agentName: string): void; + notify(run: RunLogData, capturedCount?: number): void; + recentRuns: RunLogData[]; +} + +function makeRun(overrides: Partial = {}): RunLogData { + return { + runId: `run-${Math.random().toString(36).slice(2)}`, + agent: "scout", + task: "task-1", + status: "success", + started: new Date().toISOString(), + completed: new Date().toISOString(), + durationSeconds: 1, + model: "default", + exitCode: 0, + tags: [], + prompt: "do the thing", + output: "All done.", + toolsUsed: [], + ...overrides, + }; +} + +function makeRuntime(getRuns: () => RunLogData[] = () => []) { + const repository = { + listRecentRuns: vi.fn(async () => getRuns()), + listRunsSince: vi.fn(async () => []), + readUsageSince: vi.fn(async () => []), + } as unknown as FleetRepository; + const runtime = new FleetRuntime(repository, { ...DEFAULT_SETTINGS }); + const internals = runtime as unknown as RuntimeInternals; + return { runtime, internals }; +} + +beforeEach(() => { + notices.length = 0; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-01T12:00:00")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("getFleetStatus", () => { + it("counts today's runs and pending approvals", async () => { + const runs = [ + makeRun({ started: "2026-07-01T08:00:00" }), + makeRun({ started: "2026-07-01T09:30:00" }), + makeRun({ started: "2026-06-30T23:59:00" }), // yesterday — excluded + makeRun({ + started: "2026-07-01T10:00:00", + status: "pending_approval", + approvals: [ + { tool: "Bash", status: "pending" }, + { tool: "Write", status: "approved" }, + ], + }), + ]; + const { internals, runtime } = makeRuntime(() => [...runs]); + await internals.refreshRunCaches(); + + expect(runtime.getFleetStatus()).toEqual({ running: 0, pending: 1, completedToday: 3 }); + }); + + it("caches on run-list identity: in-place mutation is invisible, refresh is picked up", async () => { + const data = [makeRun({ started: "2026-07-01T08:00:00" })]; + const { internals, runtime } = makeRuntime(() => [...data]); + await internals.refreshRunCaches(); + expect(runtime.getFleetStatus().completedToday).toBe(1); + + // Mutating the cached array in place is NOT a supported mutation point + // (recentRuns is only ever replaced wholesale by refreshRunCaches) — the + // cache deliberately keys on array identity, so this stays at 1. + internals.recentRuns.push(makeRun({ started: "2026-07-01T09:00:00" })); + expect(runtime.getFleetStatus().completedToday).toBe(1); + + // Recording a run goes through refreshRunCaches → new array → recompute. + data.push(makeRun({ started: "2026-07-01T09:00:00" })); + await internals.refreshRunCaches(); + expect(runtime.getFleetStatus().completedToday).toBe(2); + }); + + it("invalidates the cache when the local day rolls over", async () => { + const { internals, runtime } = makeRuntime(() => [makeRun({ started: "2026-07-01T23:00:00" })]); + vi.setSystemTime(new Date("2026-07-01T23:30:00")); + await internals.refreshRunCaches(); + expect(runtime.getFleetStatus().completedToday).toBe(1); + + vi.setSystemTime(new Date("2026-07-02T00:01:00")); + expect(runtime.getFleetStatus().completedToday).toBe(0); + }); +}); + +describe("run output batching", () => { + it("appends the buffer eagerly but flushes listeners at most every 100ms", () => { + const { internals, runtime } = makeRuntime(); + const received: string[] = []; + runtime.onRunOutput("scout", (chunk) => received.push(chunk)); + + internals.emitRunOutput("scout", "one "); + internals.emitRunOutput("scout", "two"); + + // Buffer is current immediately; listeners haven't been pinged yet. + expect(runtime.getRunOutputBuffer("scout")).toBe("one two"); + expect(received).toEqual([]); + + vi.advanceTimersByTime(100); + expect(received).toEqual(["one two"]); + + // A later chunk starts a fresh batch. + internals.emitRunOutput("scout", " three"); + expect(received).toEqual(["one two"]); + vi.advanceTimersByTime(100); + expect(received).toEqual(["one two", " three"]); + }); + + it("flushes pending chunks on run end before tearing down", () => { + const { internals, runtime } = makeRuntime(); + const received: string[] = []; + runtime.onRunOutput("scout", (chunk) => received.push(chunk)); + + internals.emitRunOutput("scout", "tail"); + internals.clearRunOutput("scout"); + + expect(received).toEqual(["tail"]); + expect(runtime.getRunOutputBuffer("scout")).toBe(""); + // No dangling timer fires after teardown. + vi.advanceTimersByTime(200); + expect(received).toEqual(["tail"]); + }); + + it("does not double-deliver pending chunks to a subscriber that got the buffer snapshot", () => { + const { internals, runtime } = makeRuntime(); + const first: string[] = []; + const second: string[] = []; + runtime.onRunOutput("scout", (chunk) => first.push(chunk)); + + internals.emitRunOutput("scout", "hello"); + // Subscribing flushes the batch to existing listeners, then hands the new + // listener the full buffer — so nothing is delivered twice. + runtime.onRunOutput("scout", (chunk) => second.push(chunk)); + + expect(first).toEqual(["hello"]); + expect(second).toEqual(["hello"]); + + vi.advanceTimersByTime(200); + expect(first).toEqual(["hello"]); + expect(second).toEqual(["hello"]); + }); + + it("resetRunOutput drops stale batches so the next run starts clean", () => { + const { internals, runtime } = makeRuntime(); + const received: string[] = []; + runtime.onRunOutput("scout", (chunk) => received.push(chunk)); + + internals.emitRunOutput("scout", "stale"); + internals.resetRunOutput("scout"); + + expect(runtime.getRunOutputBuffer("scout")).toBe(""); + vi.advanceTimersByTime(200); + expect(received).toEqual([]); + + internals.emitRunOutput("scout", "fresh"); + vi.advanceTimersByTime(100); + expect(received).toEqual(["fresh"]); + }); + + it("stops delivering after unsubscribe", () => { + const { internals, runtime } = makeRuntime(); + const received: string[] = []; + const unsub = runtime.onRunOutput("scout", (chunk) => received.push(chunk)); + + internals.emitRunOutput("scout", "a"); + vi.advanceTimersByTime(100); + unsub(); + internals.emitRunOutput("scout", "b"); + vi.advanceTimersByTime(100); + + expect(received).toEqual(["a"]); + }); +}); + +describe("notify memory-capture suffix", () => { + it("appends the captured count to the success notice", () => { + const { internals } = makeRuntime(); + internals.notify(makeRun({ output: "Report ready." }), 3); + expect(notices).toHaveLength(1); + expect(notices[0]?.message).toBe("✅ scout: Report ready. · captured 3 memory facts"); + }); + + it("uses singular wording for one capture", () => { + const { internals } = makeRuntime(); + internals.notify(makeRun({ output: "Report ready." }), 1); + expect(notices[0]?.message).toContain("· captured 1 memory fact"); + expect(notices[0]?.message).not.toContain("memory facts"); + }); + + it("leaves the notice unchanged when nothing was captured", () => { + const { internals } = makeRuntime(); + internals.notify(makeRun({ output: "Report ready." })); + expect(notices[0]?.message).toBe("✅ scout: Report ready."); + }); + + it("does not append the suffix to failure notices", () => { + const { internals } = makeRuntime(); + internals.notify(makeRun({ status: "failure", exitCode: 1, output: "boom" }), 2); + expect(notices[0]?.message).toBe("❌ scout: boom"); + }); +}); diff --git a/src/services/fleetRuntime.ts b/src/services/fleetRuntime.ts index fb82543..0a75399 100644 --- a/src/services/fleetRuntime.ts +++ b/src/services/fleetRuntime.ts @@ -54,6 +54,21 @@ export class FleetRuntime { private statusChangeListeners = new Set<() => void>(); private runOutputListeners = new Map void>>(); private runOutputBuffers = new Map(); + /** Chunks accumulated since the last listener flush, per agent. Live output + * is fanned out to listeners at most every OUTPUT_FLUSH_INTERVAL_MS (plus a + * final flush at run end) so large/chatty CLI output doesn't cause a render + * per stdout chunk. `runOutputBuffers` is still appended eagerly, so + * getRunOutputBuffer() is always current. */ + private runOutputPending = new Map(); + private runOutputFlushTimers = new Map>(); + private static readonly OUTPUT_FLUSH_INTERVAL_MS = 100; + /** Cached expensive parts of getFleetStatus() — it's called on every render. + * Keyed on the `recentRuns` array identity (refreshRunCaches() replaces the + * array wholesale and nothing mutates it in place, so a changed reference + * means runs/approvals changed) plus the local date, so `completedToday` + * rolls over at midnight. `running` is recomputed each call (it depends on + * runtimeState and is O(#agents)). */ + private fleetStatusCache: { runs: RunLogData[]; today: string; completedToday: number; pending: number } | null = null; /** Heartbeat cron jobs, keyed by agent name. Separate from task scheduler jobs. */ private heartbeatJobs = new Map(); /** Nightly reflection cron jobs, keyed by agent name (§8). */ @@ -183,17 +198,22 @@ export class FleetRuntime { const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; - const completedToday = this.recentRuns.filter((run) => { - const d = new Date(run.started); - const runDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; - return runDate === today; - }).length; + let cache = this.fleetStatusCache; + if (!cache || cache.runs !== this.recentRuns || cache.today !== today) { + const completedToday = this.recentRuns.filter((run) => { + const d = new Date(run.started); + const runDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + return runDate === today; + }).length; + const pending = this.recentRuns.flatMap((run) => run.approvals ?? []).filter((item) => item.status === "pending").length; + cache = { runs: this.recentRuns, today, completedToday, pending }; + this.fleetStatusCache = cache; + } const running = Array.from(this.runtimeState.values()).filter((state) => state.status === "running").length; - const pending = this.recentRuns.flatMap((run) => run.approvals ?? []).filter((item) => item.status === "pending").length; return { running, - pending, - completedToday, + pending: cache.pending, + completedToday: cache.completedToday, }; } @@ -203,6 +223,10 @@ export class FleetRuntime { } onRunOutput(agentName: string, callback: (chunk: string) => void): () => void { + // Flush any batched chunks to the *existing* listeners first: the snapshot + // handed to the new listener below already contains them (the buffer is + // appended eagerly), so leaving them pending would deliver them twice. + this.flushRunOutput(agentName); let listeners = this.runOutputListeners.get(agentName); if (!listeners) { listeners = new Set(); @@ -220,6 +244,56 @@ export class FleetRuntime { return this.runOutputBuffers.get(agentName) ?? ""; } + /** Record a live output chunk: append to the (always-current) buffer, and + * batch listener notification so a chatty CLI doesn't trigger a dashboard + * render per stdout chunk. Listeners receive the concatenated pending + * chunks at most every OUTPUT_FLUSH_INTERVAL_MS. */ + private emitRunOutput(agentName: string, chunk: string): void { + this.runOutputBuffers.set(agentName, (this.runOutputBuffers.get(agentName) ?? "") + chunk); + this.runOutputPending.set(agentName, (this.runOutputPending.get(agentName) ?? "") + chunk); + if (!this.runOutputFlushTimers.has(agentName)) { + this.runOutputFlushTimers.set( + agentName, + setTimeout(() => this.flushRunOutput(agentName), FleetRuntime.OUTPUT_FLUSH_INTERVAL_MS), + ); + } + } + + /** Deliver batched chunks (concatenated, in order) to listeners now. */ + private flushRunOutput(agentName: string): void { + const timer = this.runOutputFlushTimers.get(agentName); + if (timer !== undefined) { + clearTimeout(timer); + this.runOutputFlushTimers.delete(agentName); + } + const pending = this.runOutputPending.get(agentName); + if (!pending) return; + this.runOutputPending.delete(agentName); + const listeners = this.runOutputListeners.get(agentName); + if (listeners) { + for (const listener of listeners) listener(pending); + } + } + + /** Reset live-output state at run start (clears any stale batch/timer). */ + private resetRunOutput(agentName: string): void { + const timer = this.runOutputFlushTimers.get(agentName); + if (timer !== undefined) { + clearTimeout(timer); + this.runOutputFlushTimers.delete(agentName); + } + this.runOutputPending.delete(agentName); + this.runOutputBuffers.set(agentName, ""); + } + + /** Final flush + teardown of live-output state at run end, so listeners see + * the tail of the output before the buffer is dropped. */ + private clearRunOutput(agentName: string): void { + this.flushRunOutput(agentName); + this.runOutputBuffers.delete(agentName); + this.runOutputListeners.delete(agentName); + } + async handleVaultChange(file: TFile): Promise { await this.repository.loadFile(file); this.snapshot = this.repository.getSnapshot(); @@ -353,6 +427,11 @@ export class FleetRuntime { } this.reflectionJobs.clear(); this.reflectionsInFlight.clear(); + for (const [, timer] of this.runOutputFlushTimers) { + clearTimeout(timer); + } + this.runOutputFlushTimers.clear(); + this.runOutputPending.clear(); this.scheduler.shutdown(); } @@ -401,10 +480,17 @@ export class FleetRuntime { // is at least 10 seconds after registration. if (Date.now() - this.heartbeatRegisteredAt < 10_000) return; - // Prevent duplicate runs if the previous heartbeat is still in-flight + // Prevent duplicate runs if the previous heartbeat is still in-flight. + // The check-and-add is synchronous (no await in between), so concurrent + // ticks can't both pass the guard. if (this.heartbeatsInFlight.has(agentName)) return; this.heartbeatsInFlight.add(agentName); + // The guard must outlive enqueue(): the scheduler resolves it when the run + // is queued/started, not when it completes. runPendingTask clears the flag + // once the heartbeat run actually finishes; clear here only when no run was + // enqueued (agent gone/disabled, or enqueue threw). + let enqueued = false; try { const agent = this.repository.getAgentByName(agentName); if (!agent || !agent.enabled || !agent.heartbeatBody.trim()) return; @@ -427,8 +513,9 @@ export class FleetRuntime { reason: "heartbeat", promptOverride: agent.heartbeatBody.trim(), }); + enqueued = true; } finally { - this.heartbeatsInFlight.delete(agentName); + if (!enqueued) this.heartbeatsInFlight.delete(agentName); } } @@ -558,7 +645,7 @@ export class FleetRuntime { currentTaskId: reflectionTaskId, runStarted: started, }); - this.runOutputBuffers.set(agentName, ""); + this.resetRunOutput(agentName); this.emitStatusChange(); try { // Guarantee the legacy→v2 migration (and its raw-archive seeding) has run @@ -595,14 +682,9 @@ export class FleetRuntime { // not emit new captures or carry the capture instruction). Stream output so // the overview shows live progress. const result = await this.withReflectionSlot(() => - this.executor.execute(agent, task, prompt, (chunk) => { - const current = this.runOutputBuffers.get(agentName) ?? ""; - this.runOutputBuffers.set(agentName, current + chunk); - const listeners = this.runOutputListeners.get(agentName); - if (listeners) { - for (const listener of listeners) listener(chunk); - } - }, { suppressMemoryCapture: true }), + this.executor.execute(agent, task, prompt, (chunk) => this.emitRunOutput(agentName, chunk), { + suppressMemoryCapture: true, + }), ); const parsed = parseReflectionOutput(result.outputText); @@ -654,6 +736,9 @@ export class FleetRuntime { currentRunId: result.runId, lastRun: { ...run, filePath: runPath }, }); + // Reflection runs bypass runPendingTask, so surface failures here — + // otherwise a broken nightly reflection is invisible outside run logs. + if (runStatus === "failure") this.notify(run); return { ok: applied, message }; } catch (error) { const msg = error instanceof Error ? error.message : String(error); @@ -683,11 +768,11 @@ export class FleetRuntime { console.warn(`Agent Fleet: failed to write reflection run log for "${agentName}"`, writeErr); } this.runtimeState.set(agentName, { status: "error", lastRun }); + this.notify(run); return { ok: false, message: `Reflection failed: ${msg}` }; } finally { this.reflectionsInFlight.delete(agentName); - this.runOutputBuffers.delete(agentName); - this.runOutputListeners.delete(agentName); + this.clearRunOutput(agentName); this.emitStatusChange(); } } @@ -732,27 +817,27 @@ export class FleetRuntime { } private async runPendingTask({ task, promptOverride }: PendingRun): Promise { + // Release the cron dedup guard once the heartbeat run is truly done — see + // runHeartbeat. Manual heartbeat-tagged runs delete a flag that isn't set, + // which is harmless. + const clearHeartbeatGuard = () => { + if (task.tags.includes("heartbeat")) this.heartbeatsInFlight.delete(task.agent); + }; const agent = this.repository.getAgentByName(task.agent); if (!agent || !agent.enabled) { + clearHeartbeatGuard(); return; } const started = new Date().toISOString(); this.runtimeState.set(agent.name, { status: "running", currentTaskId: task.taskId, runStarted: started }); - this.runOutputBuffers.set(agent.name, ""); + this.resetRunOutput(agent.name); this.emitStatusChange(); try { - const result = await this.executor.execute(agent, task, promptOverride, (chunk) => { - const current = this.runOutputBuffers.get(agent.name) ?? ""; - this.runOutputBuffers.set(agent.name, current + chunk); - const listeners = this.runOutputListeners.get(agent.name); - if (listeners) { - for (const listener of listeners) { - listener(chunk); - } - } - }); + const result = await this.executor.execute(agent, task, promptOverride, (chunk) => + this.emitRunOutput(agent.name, chunk), + ); const wasAborted = this.consumeAborted(agent.name); const approvals = wasAborted ? [] : this.buildApprovals(agent, result.toolsUsed); const runStatus = wasAborted ? "cancelled" : this.resolveRunStatus(result, approvals); @@ -785,12 +870,14 @@ export class FleetRuntime { runCount: task.runCount + 1, }); + let capturedCount = 0; if (agent.memory) { const isHeartbeat = task.tags.includes("heartbeat"); const source = isHeartbeat ? "heartbeat" : `task:${task.taskId}`; const entries = extractCaptures(result.outputText); try { await this.memoryWriter.capture(agent, entries, source, new Date().toISOString()); + capturedCount = entries.length; } catch (err) { console.warn(`Agent Fleet: failed to append memory for "${agent.name}"`, err); } @@ -833,7 +920,7 @@ export class FleetRuntime { lastRun: { ...run, filePath: runPath }, }); - if (!wasAborted) this.notify(run); + if (!wasAborted) this.notify(run, capturedCount); } catch (error) { const wasAborted = this.consumeAborted(agent.name); const runStatus = wasAborted ? "cancelled" : "failure"; @@ -862,6 +949,7 @@ export class FleetRuntime { }); if (!wasAborted) this.notify(run); } finally { + clearHeartbeatGuard(); // Fold any `remember` MCP-tool captures from this run into memory (§7.5). if (agent.memory) { try { @@ -870,8 +958,7 @@ export class FleetRuntime { console.warn(`Agent Fleet: failed to drain pending memory for "${agent.name}"`, err); } } - this.runOutputBuffers.delete(agent.name); - this.runOutputListeners.delete(agent.name); + this.clearRunOutput(agent.name); this.emitStatusChange(); } } @@ -901,7 +988,7 @@ export class FleetRuntime { return result.exitCode === 0 ? "success" : "failure"; } - private notify(run: RunLogData): void { + private notify(run: RunLogData, capturedCount = 0): void { if (this.settings.notificationLevel === "none") { return; } @@ -915,9 +1002,12 @@ export class FleetRuntime { .find((l) => l && !l.startsWith("{") && !l.startsWith("[")) ?? ""; const preview = firstLine.slice(0, 120) || run.status; + // Surface memory captures on successful runs so they aren't drained silently. + const captureSuffix = + capturedCount > 0 ? ` · captured ${capturedCount} memory fact${capturedCount === 1 ? "" : "s"}` : ""; const message = run.status === "success" - ? `✅ ${run.agent}: ${preview}` + ? `✅ ${run.agent}: ${preview}${captureSuffix}` : run.status === "pending_approval" ? `🔵 ${run.agent} needs approval: ${(run.approvals ?? [])[0]?.tool ?? "tool action"}` : `❌ ${run.agent}: ${preview}`; diff --git a/src/services/mcpManager.ts b/src/services/mcpManager.ts index bfa0d64..19fac36 100644 --- a/src/services/mcpManager.ts +++ b/src/services/mcpManager.ts @@ -360,44 +360,55 @@ export class McpManager { let onError: ((err: Error) => void) | null = null; const server = http.createServer((req, res) => { - const reqUrl = new URL(req.url ?? "/", "http://localhost"); - if (reqUrl.pathname !== "/callback") { - res.writeHead(404); - res.end(); - return; - } + // A throw here (bad URL, dead socket on writeHead, …) would otherwise be + // an uncaught exception AND leave waitForCode hanging until its timeout — + // catch it, settle the promise, and best-effort close the response. + try { + const reqUrl = new URL(req.url ?? "/", "http://localhost"); + if (reqUrl.pathname !== "/callback") { + res.writeHead(404); + res.end(); + return; + } + + const error = reqUrl.searchParams.get("error"); + if (error) { + const desc = reqUrl.searchParams.get("error_description") ?? error; + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "" + + "

Authorization Failed

" + desc + "

" + + "

You can close this tab.

" + + "", + ); + onError?.(new Error(`OAuth denied: ${desc}`)); + return; + } + + const code = reqUrl.searchParams.get("code"); + const state = reqUrl.searchParams.get("state"); + if (!code || !state) { + res.writeHead(400); + res.end("Missing code or state"); + return; + } - const error = reqUrl.searchParams.get("error"); - if (error) { - const desc = reqUrl.searchParams.get("error_description") ?? error; res.writeHead(200, { "Content-Type": "text/html" }); res.end( "" - + "

Authorization Failed

" + desc + "

" - + "

You can close this tab.

" + + "

Authenticated!

" + + "

You can close this tab and return to Obsidian.

" + + "" + "", ); - onError?.(new Error(`OAuth denied: ${desc}`)); - return; + onResult?.(code, state); + } catch (err) { + try { + if (!res.headersSent) res.writeHead(500); + res.end(); + } catch { /* ignore */ } + onError?.(err instanceof Error ? err : new Error(String(err))); } - - const code = reqUrl.searchParams.get("code"); - const state = reqUrl.searchParams.get("state"); - if (!code || !state) { - res.writeHead(400); - res.end("Missing code or state"); - return; - } - - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - "" - + "

Authenticated!

" - + "

You can close this tab and return to Obsidian.

" - + "" - + "", - ); - onResult?.(code, state); }); const port = await new Promise((resolve, reject) => { @@ -433,7 +444,13 @@ export class McpManager { reject(err); }; }), - close: () => { try { server.close(); } catch { /* ignore */ } }, + close: () => { + // Drop lingering keep-alive connections first — server.close() alone + // waits for them, which would keep the port bound and break the next + // auth attempt with "address already in use". + try { server.closeAllConnections(); } catch { /* ignore */ } + try { server.close(); } catch { /* ignore */ } + }, }; } @@ -512,7 +529,12 @@ export class McpManager { }); req.on("error", reject); - const timer = window.setTimeout(() => { req.destroy(); reject(new Error("OAuth request timed out")); }, 15000); + const timer = window.setTimeout(() => { + // destroy() can throw on an already-broken socket — never let that + // skip the reject or leave the socket lingering. + try { req.destroy(); } catch { /* ignore */ } + reject(new Error("OAuth request timed out")); + }, 15000); req.on("close", () => window.clearTimeout(timer)); if (body) req.write(body); req.end(); diff --git a/src/services/mcpProjection.ts b/src/services/mcpProjection.ts index 50bc5d0..46db36a 100644 --- a/src/services/mcpProjection.ts +++ b/src/services/mcpProjection.ts @@ -23,6 +23,7 @@ // write failure returns null so the run proceeds with no fleet MCP rather than // aborting, and one bad server is dropped (logged) without poisoning the rest. +import { randomUUID } from "crypto"; import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs"; import { join } from "path"; import type { McpServer, McpTransport } from "../types"; @@ -56,9 +57,6 @@ export interface McpProjection { tempFiles: string[]; } -/** Monotonic suffix so concurrent installs in the same cwd never collide. */ -let projectionSeq = 0; - /** * Descriptor for the per-run `remember` capture tool, fed through the same * projection pipe as any other stdio server. `AF_PENDING_DIR` / `AF_SOURCE` are @@ -254,7 +252,9 @@ export function installMcpProjection( const claudeDir = join(cwd, ".claude"); try { if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true }); - const token = `${process.pid}-${Date.now()}-${projectionSeq++}`; + // Random token so concurrent installs — even across processes — never + // collide. (pid+time+counter could repeat across two plugin processes.) + const token = randomUUID(); // Prepare each server (materialize inline scripts). Drop any that fail so a // single broken definition doesn't take down the whole run. diff --git a/src/services/memoryWriter.test.ts b/src/services/memoryWriter.test.ts index 36a4c92..8d34934 100644 --- a/src/services/memoryWriter.test.ts +++ b/src/services/memoryWriter.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentConfig, MemorySection, WorkingMemory } from "../types"; import { appendEntries, emptyWorkingMemory } from "../utils/memoryFormat"; import { MemoryWriter, type MemoryStore } from "./memoryWriter"; @@ -198,6 +198,44 @@ describe("MemoryWriter", () => { expect(store.migrated).toEqual(["Agent A"]); }); + it("a hung store write does not wedge subsequent captures (lock slot times out)", async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const store = new FakeStore(); + // First write hangs forever; later writes behave normally. + let hangNext = true; + const realWrite = store.writeWorkingMemory.bind(store); + store.writeWorkingMemory = (name, wm) => { + if (hangNext) { + hangNext = false; + return new Promise(() => {}); // never settles + } + return realWrite(name, wm); + }; + const w = new MemoryWriter(store); + + // Fire-and-forget, mirroring how ChatSession invokes capture. This one + // wedges inside writeWorkingMemory and never settles. + void w.capture(agent(), [{ text: "stuck fact" }], "chat", "2026-06-13T10:00:00Z"); + // Let the first task reach the hung write before queuing the second. + await vi.advanceTimersByTimeAsync(0); + + const second = w.capture(agent(), [{ text: "later fact" }], "chat", "2026-06-13T10:00:01Z"); + // Nothing moves until the stuck task's lock slot times out. + await vi.advanceTimersByTimeAsync(9_999); + expect(store.entryTexts("Agent A")).toEqual([]); + + await vi.advanceTimersByTimeAsync(10_000); + await second; + expect(store.entryTexts("Agent A")).toContain("later fact"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("releasing its lock slot")); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); + it("enforces the hard cap, spilling oldest entries to raw-only", async () => { const store = new FakeStore(); const w = new MemoryWriter(store); diff --git a/src/services/memoryWriter.ts b/src/services/memoryWriter.ts index 23701d2..0bba476 100644 --- a/src/services/memoryWriter.ts +++ b/src/services/memoryWriter.ts @@ -190,18 +190,36 @@ export class MemoryWriter { await this.store.writeWorkingMemory(agent.name, next); } + /** Per-task timeout for the lock chain — one hung vault write must not + * wedge every later capture for the agent forever. */ + private static readonly LOCK_TIMEOUT_MS = 10_000; + /** Run `fn` after any in-flight memory op for `key` completes. FIFO. */ private withLock(key: string, fn: () => Promise): Promise { const prev = MemoryWriter.locks.get(key) ?? Promise.resolve(); - const next = prev.then(fn, fn); - // Keep the chain alive even if fn rejects, but don't swallow the caller's error. - MemoryWriter.locks.set( - key, - next.then( - () => undefined, - () => undefined, - ), - ); + // The chain link resolves when fn settles OR its timeout fires, so the + // chain survives both rejections (without swallowing the caller's error) + // and hangs. A timed-out task keeps running detached — it can't be + // cancelled — but its lock slot is released so later captures proceed. + let release!: () => void; + const link = new Promise((resolve) => { release = resolve; }); + const run = (): Promise => { + const timer = setTimeout(() => { + console.warn( + `Agent Fleet: memory write for "${key}" still pending after ` + + `${MemoryWriter.LOCK_TIMEOUT_MS}ms — releasing its lock slot`, + ); + release(); + }, MemoryWriter.LOCK_TIMEOUT_MS); + const result = fn(); + result.then( + () => { clearTimeout(timer); release(); }, + () => { clearTimeout(timer); release(); }, + ); + return result; + }; + const next = prev.then(run, run); + MemoryWriter.locks.set(key, link); return next; } } diff --git a/src/services/promptAssembly.ts b/src/services/promptAssembly.ts new file mode 100644 index 0000000..a7f76ea --- /dev/null +++ b/src/services/promptAssembly.ts @@ -0,0 +1,80 @@ +// Shared agent-prompt assembly for the one-shot run path (ExecutionManager) +// and the chat path (ChatSession). Both paths inject the same core context — +// agent body, shared skills, agent-specific skills/context, working memory, +// and wiki references — and this module is the single source of truth for +// that sequence so the two cannot silently diverge. +// +// Genuinely path-specific sections stay with their callers: +// - ExecutionManager appends the `## Task` section (task body or override). +// - ChatSession appends the `## Thread Mode` replay (side threads) and frames +// the first user message as the `## Task` section; its chat-only +// `## Channel Context` section travels through `opts.channelContext` +// because it sits BETWEEN memory and wiki access in the sequence. +import type { AgentConfig } from "../types"; +import type { FleetRepository } from "../fleetRepository"; +import { buildWikiReferencesContext } from "../utils/wikiReferences"; +import { buildMemorySection } from "../utils/memoryFormat"; + +export interface AgentPromptOptions { + /** Whether the `## Memory` section is injected. The run path passes + * `agent.memory && !suppressMemoryCapture` (reflection runs must not see — + * or be told to capture into — the memory they are consolidating); the chat + * path passes `agent.memory` unchanged. */ + memoryActive: boolean; + /** Chat-only: channel instructions (e.g. "you are talking via Slack"), + * injected after memory and before wiki access. Omitted/blank = no section. */ + channelContext?: string; +} + +/** + * Build the ordered common prompt sections for an agent. Returns the raw + * section array (possibly containing an empty agent body) — callers append + * their path-specific sections and then `filter(Boolean).join("\n\n")`. + */ +export async function buildAgentPromptSections( + repository: FleetRepository, + agent: AgentConfig, + opts: AgentPromptOptions, +): Promise { + const sections: string[] = [agent.body.trim()]; + + // Shared skills + for (const skillName of agent.skills) { + const skill = repository.getSkillByName(skillName); + if (skill) { + const parts = [skill.body.trim()]; + if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`); + if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`); + if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`); + sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`); + } + } + + // Agent-specific skills (from SKILLS.md in folder agents) + if (agent.skillsBody.trim()) { + sections.push(`## Agent Skills\n${agent.skillsBody.trim()}`); + } + + // Agent context (from CONTEXT.md in folder agents) + if (agent.contextBody.trim()) { + sections.push(`## Agent Context\n${agent.contextBody.trim()}`); + } + + if (opts.memoryActive) { + const wm = await repository.readWorkingMemory(agent.name); + const memorySection = buildMemorySection(agent, wm); + if (memorySection) sections.push(memorySection); + } + + // Channel context (chat-only) is appended after the agent's own sections so + // it takes priority without shadowing the agent's identity. + if (opts.channelContext && opts.channelContext.trim()) { + sections.push(`## Channel Context\n${opts.channelContext.trim()}`); + } + + // Wiki references — consumer-mode access to one or more Wiki Keeper scopes. + const wikiContext = buildWikiReferencesContext(agent, repository); + if (wikiContext) sections.push(wikiContext); + + return sections; +} diff --git a/src/services/rememberMcpServer.ts b/src/services/rememberMcpServer.ts index f461557..86ed196 100644 --- a/src/services/rememberMcpServer.ts +++ b/src/services/rememberMcpServer.ts @@ -117,6 +117,7 @@ function handle(req) { } `; +import { randomUUID } from "crypto"; import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs"; import { join } from "path"; import { normalizeAdapter } from "../adapters"; @@ -134,17 +135,15 @@ export interface RememberToolInstall { tempFiles: string[]; } -/** Monotonic suffix so concurrent installs in the same cwd never collide. */ -let installSeq = 0; - /** * Write the per-run temp MCP server script + config into `/.claude` and * return the config path (for `--mcp-config`) plus the temp files to clean up. * Returns null when there is no absolute pending dir (e.g. mobile vault). * - * Filenames are made UNIQUE per install (pid + time + counter) so two agents or - * a task+chat running in the same cwd (the default vault root) can't clobber - * each other's config or have one run's cleanup delete a peer's live files. + * Filenames are made UNIQUE per install (random UUID) so two agents or a + * task+chat running in the same cwd (the default vault root) — even across + * processes — can't clobber each other's config or have one run's cleanup + * delete a peer's live files. */ export function installRememberTool( cwd: string, @@ -158,7 +157,7 @@ export function installRememberTool( try { const claudeDir = join(cwd, ".claude"); if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true }); - const token = `${process.pid}-${Date.now()}-${installSeq++}`; + const token = randomUUID(); const scriptPath = join(claudeDir, `af-remember-mcp.${token}.cjs`); const configPath = join(claudeDir, `af-remember-mcp.${token}.json`); writeFileSync(scriptPath, REMEMBER_MCP_SERVER_SOURCE, "utf-8"); diff --git a/src/utils/claudeSettings.ts b/src/utils/claudeSettings.ts index a5f5745..5add313 100644 --- a/src/utils/claudeSettings.ts +++ b/src/utils/claudeSettings.ts @@ -89,16 +89,34 @@ export function writeClaudeSettingsFile( } /** Best-effort cleanup. Restores the user's pre-existing settings.local.json - * if one was backed up, otherwise removes the file we wrote. Errors swallow. */ + * if one was backed up, otherwise removes the file we wrote. Never throws + * (this runs in spawn handlers), but failures are logged: a leftover + * agent-written settings file silently contaminates the next Claude run in + * that directory. If restoring the backup fails, we fall back to deleting + * the file so at least the agent's temporary config doesn't persist. */ export function restoreClaudeSettingsFile(state: ClaudeSettingsState | null): void { if (!state) return; - try { - if (state.backupContent !== null) { + if (state.backupContent !== null) { + try { writeFileSync(state.path, state.backupContent, "utf-8"); - } else if (existsSync(state.path)) { + return; + } catch (err) { + console.warn( + `Agent Fleet: failed to restore the previous ${state.path} ` + + `(${err instanceof Error ? err.message : String(err)}); ` + + `deleting the temporary agent settings file instead so it doesn't affect later runs.`, + ); + } + } + try { + if (existsSync(state.path)) { unlinkSync(state.path); } - } catch { - // Best-effort cleanup — don't crash a spawn handler over this. + } catch (err) { + console.warn( + `Agent Fleet: failed to remove the temporary agent settings file at ${state.path} ` + + `(${err instanceof Error ? err.message : String(err)}). ` + + `Stale agent permissions may leak into the next Claude run in this directory — delete it manually.`, + ); } } diff --git a/src/views/agentChatView.ts b/src/views/agentChatView.ts index e03b78f..8c0a87a 100644 --- a/src/views/agentChatView.ts +++ b/src/views/agentChatView.ts @@ -1894,7 +1894,7 @@ export class AgentChatView extends ItemView { // timeout, etc). Render a red error bubble so the user knows // exactly what happened; state clean-up is handled by the session. const msg = event.errorMessage?.trim() || "The agent's run ended with an error."; - this.addBubble("error", `Error: ${msg}`); + this.addErrorBubble(msg); } else if (event.type === "compacted") { // /compact completed — not a bubble anymore. The session writes // `stats.lastCompact` and re-emits stats, so the inline notice @@ -1930,11 +1930,19 @@ export class AgentChatView extends ItemView { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg !== "Aborted") { - this.addBubble("error", `Error: ${msg}`); + this.addErrorBubble(msg); } } } + /** Render a red error bubble, plus a muted one-line recovery hint below + * the message when the error matches a known failure mode. */ + private addErrorBubble(msg: string): void { + const bubble = this.addBubble("error", `Error: ${msg}`); + const hint = recoveryHintFor(msg); + if (hint) bubble.createDiv({ cls: "af-chat-error-hint", text: hint }); + } + /** "+ New chat" creates a brand-new conversation alongside any existing * ones, leaving every other chat's history intact. */ private async handleNewChat(): Promise { @@ -1989,12 +1997,34 @@ export class AgentChatView extends ItemView { }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); if (msg !== "Aborted") { - this.addBubble("error", `Error: ${msg}`); + this.addErrorBubble(msg); } }); } } +/** Pattern-match common CLI/API failures and return a one-line recovery + * hint, or null when the error isn't recognized (no hint rendered). */ +export function recoveryHintFor(errorText: string): string | null { + const t = errorText.toLowerCase(); + if (/context\s*(window|length)|prompt is too long|too many tokens|token limit|maximum context|context.{0,20}exceed/.test(t)) { + return "Try a smaller model, or start a new session to clear history."; + } + if (/rate.?limit|\b429\b|overloaded|too many requests/.test(t)) { + return "Wait a minute and retry, or check your API quota."; + } + if (/\b401\b|unauthorized|api.?key|authentication|invalid.{0,10}credential|not logged in/.test(t)) { + return "Check your API key / CLI login (run the CLI's login command in a terminal)."; + } + if (/timed out|time.?out|watchdog/.test(t)) { + return "A tool may be stuck. Retry, or raise the Chat Watchdog Timeout in Settings."; + } + if (/enoent|command not found|no such file or directory/.test(t)) { + return "CLI binary not found — check the CLI path in Settings."; + } + return null; +} + function formatTokens(n: number): string { if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`; return `${n}`; diff --git a/src/views/dashboardView.ts b/src/views/dashboardView.ts index ddf18c7..389d65e 100644 --- a/src/views/dashboardView.ts +++ b/src/views/dashboardView.ts @@ -1,18 +1,44 @@ import { ItemView, MarkdownRenderer, Notice, TFile, WorkspaceLeaf, setIcon } from "obsidian"; -import { IconPickerModal } from "../modals/iconPickerModal"; import { VIEW_TYPE_DASHBOARD } from "../constants"; import type AgentFleetPlugin from "../main"; -import type { AgentConfig, AgentHealth, ChannelConfig, McpServer, McpTool, RunLogData, SkillConfig, TaskConfig, UsageRecord } from "../types"; +import type { AgentConfig, AgentHealth, McpTool, RunLogData, TaskConfig, UsageRecord } from "../types"; import { estimateCostFromBreakdown, estimateCostFromTotal } from "../utils/pricing"; -import { truncate, slugify, parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "../utils/markdown"; +import { truncate, parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "../utils/markdown"; import { splitLines } from "../utils/platform"; import { createIcon } from "../utils/icons"; import { renderBarChart, renderDonutChart } from "../components/chartRenderer"; import type { BarChartDay } from "../components/chartRenderer"; import { makeDraggable, makeDropTarget } from "../components/dragDrop"; -import { CODEX_MODEL_ALIASES, MODEL_ALIASES, renderModelPicker } from "../components/modelPicker"; -import { renderSections } from "../utils/memoryFormat"; -import { parseLatestLintReport } from "../utils/wikiLintReport"; +import { renderChannelForm } from "./forms/channelForm"; +import type { ChannelFormDeps } from "./forms/channelForm"; +import { renderCreateAgentForm, renderEditAgentForm } from "./forms/agentForm"; +import type { AgentFormDeps } from "./forms/agentForm"; +import { renderCreateTaskForm, renderEditTaskForm } from "./forms/taskForm"; +import type { TaskFormDeps } from "./forms/taskForm"; +import { renderSkillForm } from "./forms/skillForm"; +import type { SkillFormDeps } from "./forms/skillForm"; +import { renderAddMcpServerForm } from "./forms/mcpForm"; +import type { McpFormDeps } from "./forms/mcpForm"; +import type { DashboardFormDeps } from "./forms/shared"; +import { renderAgentsPage } from "./pages/agentsPage"; +import type { AgentsPageDeps } from "./pages/agentsPage"; +import { renderAgentDetailPage } from "./pages/agentDetailPage"; +import type { AgentDetailPageDeps } from "./pages/agentDetailPage"; +import { renderRunsPage } from "./pages/runsPage"; +import type { RunsPageDeps } from "./pages/runsPage"; +import { renderSkillsPage } from "./pages/skillsPage"; +import type { SkillsPageDeps } from "./pages/skillsPage"; +import { renderApprovalsPage } from "./pages/approvalsPage"; +import type { ApprovalsPageDeps } from "./pages/approvalsPage"; +import { renderChannelsPage } from "./pages/channelsPage"; +import type { ChannelsPageDeps } from "./pages/channelsPage"; +import { renderWikiKeepersPage } from "./pages/wikiKeepersPage"; +import { renderTaskDetailPage } from "./pages/taskDetailPage"; +import type { TaskDetailPageDeps } from "./pages/taskDetailPage"; +import { renderMcpPage } from "./pages/mcpPage"; +import type { McpPageDeps } from "./pages/mcpPage"; +import type { DashboardPageDeps } from "./pages/shared"; +import { DEFAULT_FILES } from "../defaults"; @@ -86,81 +112,32 @@ const PAGE_ICON_NAMES: Record = { const MAIN_PAGES: DashboardPage[] = ["dashboard", "agents", "kanban", "runs", "approvals", "skills", "wiki-keepers", "mcp", "channels"]; -/* Note: The per-adapter model dropdown was replaced by the ModelPicker - * component (src/components/modelPicker.ts). Aliases and custom IDs are - * preferred over hardcoded lists because they work across all backends - * (Anthropic direct, Bedrock, Vertex, Foundry, Mantle) without updates. */ +/** localStorage key (vault-scoped via App.saveLocalStorage) for the one-time + * first-run welcome card dismissal. */ +const WELCOME_DISMISS_KEY = "agent-fleet-welcome-dismissed"; -// Adapter choices in the agent forms. "process"/"http" stay greyed out until -// those backends exist. -const ADAPTER_FORM_OPTIONS: Array<[string, string, boolean]> = [ - ["claude-code", "Claude Code", false], - ["codex", "Codex", false], - ["process", "Process (coming soon)", true], - ["http", "HTTP (coming soon)", true], -]; - -// Permission modes per adapter: [value, label, description]. -// Claude Code uses its native --permission-mode vocabulary; Codex maps onto -// sandbox levels (`codex exec` never prompts, so the sandbox is the only -// enforcement axis). -const CLAUDE_PERM_MODE_OPTIONS: Array<[string, string, string]> = [ - ["bypassPermissions", "Bypass Permissions", "Auto-approve everything except deny list"], - ["dontAsk", "Don’t Ask", "Auto-approve all tool calls"], - ["acceptEdits", "Accept Edits", "Auto-approve file edits, block bash unless allowed"], - ["plan", "Plan", "Read-only mode, no writes or commands"], - ["default", "Default", "Ask for each tool call"], -]; -const CODEX_PERM_MODE_OPTIONS: Array<[string, string, string]> = [ - ["bypassPermissions", "Bypass (no sandbox)", "No sandbox, auto-approve everything"], - ["workspace-write", "Workspace Write", "Sandboxed: writes only inside the working dir"], - ["read-only", "Read Only", "Sandboxed: no writes or side-effect commands"], -]; - -function isCodexAdapterValue(adapter: string): boolean { - const v = adapter.trim().toLowerCase(); - return v === "codex" || v === "openai-codex"; -} - -function permModeOptionsFor(adapter: string): Array<[string, string, string]> { - return isCodexAdapterValue(adapter) ? CODEX_PERM_MODE_OPTIONS : CLAUDE_PERM_MODE_OPTIONS; -} - -/** Translate a permission-mode value to the nearest equivalent when the user - * switches the form's adapter, so the dropdown always shows a valid choice. */ -function permModeForAdapter(value: string, adapter: string): string { - if (isCodexAdapterValue(adapter)) { - switch (value) { - case "acceptEdits": - case "default": - return "workspace-write"; - case "plan": - return "read-only"; - case "dontAsk": - return "bypassPermissions"; - default: - return CODEX_PERM_MODE_OPTIONS.some(([v]) => v === value) ? value : "bypassPermissions"; - } - } - switch (value) { - case "workspace-write": - return "acceptEdits"; - case "read-only": - return "plan"; - case "danger-full-access": - return "bypassPermissions"; - default: - return CLAUDE_PERM_MODE_OPTIONS.some(([v]) => v === value) ? value : "bypassPermissions"; - } -} +/** Names of the agents bundled as defaults (seeded by ensureSamples). Derived + * from DEFAULT_FILES paths so the "pristine fleet" check tracks the catalog. */ +const DEFAULT_AGENT_NAMES: ReadonlySet = new Set( + DEFAULT_FILES + .map((f) => /^agents\/([^/]+?)(?:\.md)?(?:\/|$)/.exec(f.path)?.[1]) + .filter((n): n is string => !!n), +); export class FleetDashboardView extends ItemView { private currentPage: DashboardPage = "dashboard"; private detailContext?: string; private agentDetailTab = "overview"; private streamingUnsubscribes: (() => void)[] = []; + /** Per-render tick callbacks for running kanban cards, driven by one shared + * interval instead of one interval per card. Cleared in cleanupStreaming. */ + private runningCardTickers: Array<() => void> = []; + private runningCardInterval?: number; private channelStatusUnsubscribe?: () => void; private authenticatingServers = new Set(); + /** Transient per-server tool probe results (name → tools), populated by the + * on-demand "Probe tools" action. Not persisted. */ + private mcpProbeCache = new Map(); constructor( leaf: WorkspaceLeaf, @@ -226,34 +203,34 @@ export class FleetDashboardView extends ItemView { this.renderDashboardPage(pageContainer); break; case "agents": - this.renderAgentsPage(pageContainer); + renderAgentsPage(pageContainer, this.agentsPageDeps()); break; case "kanban": this.renderKanbanPage(pageContainer); break; case "runs": - this.renderRunsPage(pageContainer); + renderRunsPage(pageContainer, this.runsPageDeps()); break; case "skills": - this.renderSkillsPage(pageContainer); + renderSkillsPage(pageContainer, this.skillsPageDeps()); break; case "approvals": - this.renderApprovalsPage(pageContainer); + renderApprovalsPage(pageContainer, this.approvalsPageDeps()); break; case "mcp": - this.renderMcpPage(pageContainer); + renderMcpPage(pageContainer, this.mcpPageDeps()); break; case "channels": - this.renderChannelsPage(pageContainer); + renderChannelsPage(pageContainer, this.channelsPageDeps()); break; case "wiki-keepers": - void this.renderWikiKeepersPage(pageContainer); + void renderWikiKeepersPage(pageContainer, this.basePageDeps()); break; case "agent-detail": - this.renderAgentDetailPage(pageContainer); + renderAgentDetailPage(pageContainer, this.agentDetailPageDeps(), this.detailContext); break; case "task-detail": - this.renderTaskDetailPage(pageContainer); + renderTaskDetailPage(pageContainer, this.taskDetailPageDeps(), this.detailContext); break; case "create-agent": this.renderCreateAgentPage(pageContainer); @@ -295,6 +272,11 @@ export class FleetDashboardView extends ItemView { unsub(); } this.streamingUnsubscribes = []; + this.runningCardTickers = []; + if (this.runningCardInterval !== undefined) { + window.clearInterval(this.runningCardInterval); + this.runningCardInterval = undefined; + } } // ═══════════════════════════════════════════════════════ @@ -399,8 +381,12 @@ export class FleetDashboardView extends ItemView { cls: "af-search-input", attr: { type: "text", placeholder: "Search agents, tasks, runs..." }, }); + let searchDebounce: number | undefined; searchInput.addEventListener("input", () => { - this.handleSearch(searchInput.value, searchWrap); + window.clearTimeout(searchDebounce); + searchDebounce = window.setTimeout(() => { + this.handleSearch(searchInput.value, searchWrap); + }, 200); }); searchInput.addEventListener("blur", () => { window.setTimeout(() => searchWrap.querySelector(".af-search-results")?.remove(), 200); @@ -474,6 +460,9 @@ export class FleetDashboardView extends ItemView { const runs = this.plugin.runtime.getRecentRuns(); const status = this.plugin.runtime.getFleetStatus(); + // First-run welcome card (pristine fleet only, dismissible) + this.maybeRenderWelcomeCard(page, snapshot.agents, runs); + // Approval banners const pendingApprovals = runs.filter((run) => (run.approvals ?? []).some((a) => a.status === "pending"), @@ -525,6 +514,77 @@ export class FleetDashboardView extends ItemView { this.renderFleetStatusPanel(split, snapshot); } + /** + * First-run welcome card: shown while the fleet is pristine — every agent is + * one of the bundled defaults (seeded by ensureSamples) and nothing has run + * yet — and the user hasn't dismissed it. Dismissal persists via Obsidian's + * vault-scoped localStorage (App.saveLocalStorage), not plugin settings. + */ + private maybeRenderWelcomeCard(container: HTMLElement, agents: AgentConfig[], runs: RunLogData[]): void { + if (this.plugin.app.loadLocalStorage(WELCOME_DISMISS_KEY)) return; + const pristine = + agents.length > 0 && + agents.every((a) => DEFAULT_AGENT_NAMES.has(a.name)) && + runs.length === 0; + if (!pristine) return; + + const card = container.createDiv({ cls: "af-welcome-card" }); + + const header = card.createDiv({ cls: "af-welcome-header" }); + const titleEl = header.createDiv({ cls: "af-section-title" }); + createIcon(titleEl, "sparkles"); + titleEl.appendText(" Welcome to Agent Fleet"); + const dismissBtn = header.createEl("button", { + cls: "clickable-icon af-welcome-dismiss", + attr: { "aria-label": "Dismiss" }, + }); + setIcon(dismissBtn, "x"); + dismissBtn.onclick = () => { + this.plugin.app.saveLocalStorage(WELCOME_DISMISS_KEY, "1"); + card.remove(); + }; + + card.createDiv({ + cls: "af-welcome-sub", + text: "Three quick steps to get your fleet going:", + }); + + const steps = card.createDiv({ cls: "af-welcome-steps" }); + const addStep = (num: number, icon: string, title: string, desc: string, onClick: () => void) => { + const step = steps.createDiv({ cls: "af-welcome-step" }); + step.createDiv({ cls: "af-welcome-step-num", text: String(num) }); + const body = step.createDiv({ cls: "af-welcome-step-body" }); + const titleRow = body.createDiv({ cls: "af-welcome-step-title" }); + createIcon(titleRow, icon, "af-meta-icon"); + titleRow.appendText(` ${title}`); + body.createDiv({ cls: "af-welcome-step-desc", text: desc }); + step.onclick = onClick; + }; + + const orchestrator = agents.find((a) => a.name === "fleet-orchestrator") ?? agents[0]; + addStep( + 1, + "message-circle", + "Try the Fleet Orchestrator in Chat", + "It knows this plugin inside out — ask it to build agents, tasks, and skills for you.", + () => void this.plugin.openChatView(orchestrator?.name), + ); + addStep( + 2, + "bot", + "Create your own agent", + "Give it a system prompt, a model, and permissions.", + () => this.navigate("create-agent"), + ); + addStep( + 3, + "radio", + "Connect a channel (optional)", + "Talk to your agents from Slack, Telegram, or Discord.", + () => this.navigate("channels"), + ); + } + private renderChartsRow(container: HTMLElement, runs: RunLogData[], chartRuns: RunLogData[]): void { const row = container.createDiv({ cls: "af-charts-row" }); @@ -787,7 +847,10 @@ export class FleetDashboardView extends ItemView { const list = section.createDiv({ cls: "af-agent-mini-list" }); if (snapshot.agents.length === 0) { - this.renderEmptyState(list, "bot", "No agents yet", "Create your first agent to get started"); + this.renderEmptyState(list, "bot", "No agents yet", "Create your first agent to get started", { + label: "New Agent", + onClick: () => void this.plugin.createAgentTemplate(), + }); return; } @@ -855,494 +918,18 @@ export class FleetDashboardView extends ItemView { item.onclick = () => this.navigate("agent-detail", agent.name); } - // ═══════════════════════════════════════════════════════ - // Agents Page - // ═══════════════════════════════════════════════════════ - - private renderAgentsPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-agents-page" }); - const snapshot = this.plugin.runtime.getSnapshot(); - - const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "Agents" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(newBtn, "plus", "af-btn-icon"); - newBtn.appendText(" New Agent"); - newBtn.onclick = () => void this.plugin.createAgentTemplate(); - - const grid = page.createDiv({ cls: "af-agents-grid" }); - - if (snapshot.agents.length === 0) { - this.renderEmptyState(grid, "bot", "No agents configured", "Create your first agent to get started"); - return; - } - - for (const agent of snapshot.agents) { - this.renderAgentCard(grid, agent, snapshot); - } - } - - private renderAgentCard( - container: HTMLElement, - agent: AgentConfig, - snapshot: { tasks: TaskConfig[]; skills: SkillConfig[] }, - ): void { - const state = this.plugin.runtime.getAgentState(agent.name); - const agentRuns = this.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name); - - const card = container.createDiv({ cls: `af-agent-card${agent.enabled ? "" : " disabled"}` }); - - // Header - const header = card.createDiv({ cls: "af-agent-card-header" }); - const avatarCls = agent.enabled ? this.healthToClass(state.status) : "disabled"; - const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` }); - this.renderAgentAvatar(avatar, agent); - - const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" }); - const nameRow = titleBlock.createDiv({ cls: "af-agent-card-name" }); - nameRow.appendText(agent.name); - if (agent.heartbeatEnabled && agent.heartbeatSchedule) { - const hbIcon = nameRow.createSpan({ cls: "af-heartbeat-indicator" }); - setIcon(hbIcon, "heart-pulse"); - hbIcon.title = `Heartbeat: ${agent.heartbeatSchedule}`; - } - titleBlock.createDiv({ cls: "af-agent-card-desc", text: agent.description ?? "No description" }); - - const toggle = header.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` }); - toggle.onclick = (e) => { - e.stopPropagation(); - void this.plugin.toggleAgent(agent.name, !agent.enabled); - }; - - // Stats - const stats = card.createDiv({ cls: "af-agent-card-stats" }); - const totalRuns = agentRuns.length; - const successRuns = agentRuns.filter((r) => r.status === "success").length; - const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0; - const avgTime = - totalRuns > 0 - ? Math.round(agentRuns.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) - : 0; - const totalTokens = agentRuns.reduce((s, r) => s + (r.tokensUsed ?? 0), 0); - - this.renderAgentStat(stats, String(totalRuns), "Runs"); - this.renderAgentStat(stats, `${successRate}%`, "Success"); - this.renderAgentStat(stats, `${avgTime}s`, "Avg Time"); - this.renderAgentStat(stats, formatTokenCount(totalTokens), "Tokens"); - - // Skills - if (agent.skills.length > 0) { - const skillsRow = card.createDiv({ cls: "af-agent-card-skills" }); - for (const skill of agent.skills) { - skillsRow.createSpan({ cls: "af-skill-tag", text: skill }); - } - } - - // Footer - const footer = card.createDiv({ cls: "af-agent-card-footer" }); - const metaParts: string[] = [`Model: ${agent.model}`]; - if (agent.approvalRequired.length > 0) { - metaParts.push(`Approval: ${agent.approvalRequired.join(", ")}`); - } - if (agent.memory) metaParts.push("Memory: on"); - if (!agent.enabled) metaParts.unshift("Disabled"); - footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") }); - - const actions = footer.createDiv({ cls: "af-agent-card-actions" }); - - if (!agent.enabled) { - const enableBtn = actions.createEl("button", { cls: "af-btn-sm", text: "Enable" }); - enableBtn.onclick = (e) => { - e.stopPropagation(); - void this.plugin.toggleAgent(agent.name, true); - }; - } - - const editBtn = actions.createEl("button", { cls: "af-btn-sm" }); - createIcon(editBtn, "edit", "af-btn-icon"); - editBtn.appendText(" Edit"); - editBtn.onclick = (e) => { - e.stopPropagation(); - this.navigate("edit-agent", agent.name); - }; - - if (agent.enabled) { - const runBtn = actions.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(runBtn, "play", "af-btn-icon"); - runBtn.appendText(" Run"); - runBtn.onclick = (e) => { - e.stopPropagation(); - void this.plugin.runAgentPrompt(agent.name); - }; - } - - card.onclick = () => this.navigate("agent-detail", agent.name); - } - private renderAgentStat(container: HTMLElement, value: string, label: string): void { const stat = container.createDiv({ cls: "af-agent-stat" }); stat.createSpan({ cls: "af-agent-stat-value", text: value }); stat.createSpan({ cls: "af-agent-stat-label", text: label }); } - // ═══════════════════════════════════════════════════════ - // Agent Detail Page - // ═══════════════════════════════════════════════════════ - - private renderAgentDetailPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-agent-detail-page" }); - const agentName = this.detailContext; - if (!agentName) { - this.renderEmptyState(page, "bot", "No agent selected", "Select an agent from the list"); - return; - } - - const agent = this.plugin.runtime.getSnapshot().agents.find((a) => a.name === agentName); - if (!agent) { - this.renderEmptyState(page, "bot", "Agent not found", `Agent "${agentName}" was not found`); - return; - } - - const state = this.plugin.runtime.getAgentState(agent.name); - const agentRuns = this.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatar = headerLeft.createDiv({ - cls: `af-agent-card-avatar ${this.healthToClass(state.status)}`, - }); - this.renderAgentAvatar(avatar, agent); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: agent.name }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: agent.description ?? "No description" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - - // Chat — primary action - const chatBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(chatBtn, "message-circle", "af-btn-icon"); - chatBtn.appendText(" Chat"); - chatBtn.onclick = () => this.openChatSlideover(agent); - - // Run Now / Enable — secondary - if (agent.enabled) { - const runBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); - createIcon(runBtn, "play", "af-btn-icon"); - runBtn.appendText(" Run Now"); - runBtn.onclick = () => void this.plugin.runAgentPrompt(agent.name); - - const pauseBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); - createIcon(pauseBtn, "pause", "af-btn-icon"); - pauseBtn.appendText(" Disable"); - pauseBtn.onclick = () => void this.plugin.toggleAgent(agent.name, false); - } else { - const enableBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); - createIcon(enableBtn, "play", "af-btn-icon"); - enableBtn.appendText(" Enable"); - enableBtn.onclick = () => void this.plugin.toggleAgent(agent.name, true); - } - - const detailEditBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); - createIcon(detailEditBtn, "edit", "af-btn-icon"); - detailEditBtn.appendText(" Edit"); - detailEditBtn.onclick = () => this.navigate("edit-agent", agent.name); - - const deleteBtn = headerActions.createEl("button", { cls: "af-btn-sm danger" }); - createIcon(deleteBtn, "trash-2", "af-btn-icon"); - deleteBtn.appendText(" Delete"); - deleteBtn.onclick = () => void this.plugin.deleteAgent(agent.name); - - // Tabs - const tabs = page.createDiv({ cls: "af-detail-tabs" }); - const tabDefs = [ - { id: "overview", label: "Overview", icon: "layout-dashboard" }, - { id: "config", label: "Config", icon: "settings" }, - { id: "runs", label: "Runs", icon: "scroll-text" }, - { id: "memory", label: "Memory", icon: "file-text" }, - ]; - for (const t of tabDefs) { - const tabBtn = tabs.createEl("button", { - cls: `af-detail-tab${this.agentDetailTab === t.id ? " active" : ""}`, - }); - createIcon(tabBtn, t.icon, "af-tab-icon"); - tabBtn.appendText(` ${t.label}`); - tabBtn.onclick = () => { - this.agentDetailTab = t.id; - void this.render(); - }; - } - - const tabContent = page.createDiv({ cls: "af-detail-tab-content" }); - switch (this.agentDetailTab) { - case "overview": - this.renderAgentOverviewTab(tabContent, agent, agentRuns); - break; - case "config": - this.renderAgentConfigTab(tabContent, agent); - break; - case "runs": - this.renderAgentRunsTab(tabContent, agentRuns); - break; - case "memory": - void this.renderAgentMemoryTab(tabContent, agent); - break; - } - } - - private renderAgentOverviewTab(container: HTMLElement, agent: AgentConfig, runs: RunLogData[]): void { - // Stats row - const statsRow = container.createDiv({ cls: "af-dash-grid" }); - const totalRuns = runs.length; - const successRuns = runs.filter((r) => r.status === "success").length; - const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0; - const avgTime = totalRuns > 0 ? Math.round(runs.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) : 0; - // Comprehensive: this agent's run logs + its chat/channel usage over the window. - const agentUsage = this.plugin.runtime.getUsageRecords().filter((u) => u.agent === agent.name); - const { tokens: totalTokens, cost: totalCostAgent } = this.combinedTotals(runs, agentUsage); - const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : ""; - - this.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time"); - this.renderStatCard(statsRow, "Success Rate", `${successRate}%`, "", "check-circle-2", `${successRuns}/${totalRuns}`); - this.renderStatCard(statsRow, "Avg Time", `${avgTime}s`, "", "clock", "per run"); - this.renderStatCard(statsRow, "Total Tokens", formatTokenCount(totalTokens), "", "zap", `all time${costSuffixAgent}`); - - // Heartbeat — compact status only (instruction shown in Config tab) - if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) { - const hbSection = container.createDiv({ cls: "af-section-card" }); - const hbHeader = hbSection.createDiv({ cls: "af-section-header" }); - const hbTitle = hbHeader.createDiv({ cls: "af-section-title" }); - createIcon(hbTitle, "heart-pulse"); - hbTitle.appendText(" Heartbeat"); - - const hbActions = hbHeader.createDiv({ cls: "af-detail-header-actions" }); - const hbToggle = hbActions.createDiv({ cls: `af-agent-card-toggle${agent.heartbeatEnabled ? " on" : ""}` }); - hbToggle.onclick = async () => { - const isOn = hbToggle.hasClass("on"); - await this.plugin.repository.updateHeartbeat(agent.name, { enabled: !isOn }); - await this.plugin.refreshFromVault(); - new Notice(`Heartbeat ${!isOn ? "enabled" : "paused"} for ${agent.name}`); - }; - - const hbBody = hbSection.createDiv({ cls: "af-config-form" }); - this.renderConfigRow(hbBody, "Schedule", cronToHuman(agent.heartbeatSchedule)); - const nextRun = this.plugin.runtime.getNextHeartbeat(agent.name); - if (nextRun && agent.heartbeatEnabled) { - this.renderConfigRow(hbBody, "Next run", this.timeUntil(nextRun)); - } - if (agent.heartbeatChannel) { - this.renderConfigRow(hbBody, "Channel", agent.heartbeatChannel); - } - } - - // Skills - if (agent.skills.length > 0) { - const skillsSection = container.createDiv({ cls: "af-section-card" }); - const skillsHeader = skillsSection.createDiv({ cls: "af-section-header" }); - const skillsTitle = skillsHeader.createDiv({ cls: "af-section-title" }); - createIcon(skillsTitle, "puzzle"); - skillsTitle.appendText(" Skills"); - const skillsBody = skillsSection.createDiv({ cls: "af-detail-skills-list" }); - for (const skill of agent.skills) { - skillsBody.createSpan({ cls: "af-skill-tag", text: skill }); - } - } - - // MCP Servers - const agentMcpServers = agent.mcpServers ?? []; - if (agentMcpServers.length > 0) { - const mcpSection = container.createDiv({ cls: "af-section-card" }); - const mcpHeader = mcpSection.createDiv({ cls: "af-section-header" }); - const mcpTitle = mcpHeader.createDiv({ cls: "af-section-title" }); - createIcon(mcpTitle, "plug"); - mcpTitle.appendText(" MCP Servers"); - const mcpBody = mcpSection.createDiv({ cls: "af-mcp-overview-list" }); - - const cachedServers = this.plugin.repository.getMcpServers(); - for (const serverName of agentMcpServers) { - const serverInfo = cachedServers.find((s) => s.name === serverName); - const row = mcpBody.createDiv({ cls: "af-mcp-overview-row" }); - const dot = row.createSpan({ - cls: `af-mcp-status-dot ${serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "disconnected"}`, - }); - dot.title = serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "unknown"; - row.createSpan({ cls: "af-mcp-overview-name", text: serverName }); - const toolCount = serverInfo?.toolDetails.length ?? serverInfo?.tools.length ?? 0; - if (toolCount > 0) { - row.createSpan({ cls: "af-mcp-overview-tools", text: `${toolCount} tools` }); - } else if (serverInfo && !serverInfo.enabled) { - row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "disabled" }); - } else if (serverInfo?.status === "needs-auth") { - row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "needs auth" }); - } - } - } - - // Permissions - const hasPermRules = agent.permissionRules.allow.length > 0 || agent.permissionRules.deny.length > 0; - if (hasPermRules || (agent.permissionMode && agent.permissionMode !== "default")) { - const permSection = container.createDiv({ cls: "af-section-card" }); - const permHeader = permSection.createDiv({ cls: "af-section-header" }); - const permTitle = permHeader.createDiv({ cls: "af-section-title" }); - createIcon(permTitle, "shield-check"); - permTitle.appendText(" Permissions"); - const permBody = permSection.createDiv({ cls: "af-config-form" }); - this.renderConfigRow(permBody, "Mode", agent.permissionMode || "default"); - if (agent.permissionRules.allow.length > 0) { - this.renderConfigRow(permBody, "Allowed", agent.permissionRules.allow.join(", ")); - } - if (agent.permissionRules.deny.length > 0) { - this.renderConfigRow(permBody, "Denied", agent.permissionRules.deny.join(", ")); - } - } - - // Recent runs - const runsSection = container.createDiv({ cls: "af-section-card" }); - const runsHeader = runsSection.createDiv({ cls: "af-section-header" }); - const runsTitle = runsHeader.createDiv({ cls: "af-section-title" }); - createIcon(runsTitle, "scroll-text"); - runsTitle.appendText(" Recent Runs"); - const runsBody = runsSection.createDiv({ cls: "af-timeline" }); - - if (runs.length === 0) { - this.renderEmptyState(runsBody, "scroll-text", "No runs yet", ""); - } else { - for (const run of runs.slice(0, 5)) { - this.renderTimelineItem(runsBody, run); - } - } - } - - private renderAgentConfigTab(container: HTMLElement, agent: AgentConfig): void { - const form = container.createDiv({ cls: "af-config-form" }); - - this.renderConfigRow(form, "Name", agent.name); - this.renderConfigRow(form, "Description", agent.description ?? ""); - this.renderConfigRow(form, "Model", agent.model); - this.renderConfigRow(form, "Timeout", `${agent.timeout}s`); - this.renderConfigRow(form, "Working Directory", agent.cwd ?? "(vault root)"); - this.renderConfigRow(form, "Permission Mode", agent.permissionMode || "default"); - this.renderConfigRow(form, "Approval Required", agent.approvalRequired.join(", ") || "none"); - if (agent.permissionRules.allow.length > 0) { - this.renderConfigRow(form, "Allowed Commands", agent.permissionRules.allow.join(", ")); - } - if (agent.permissionRules.deny.length > 0) { - this.renderConfigRow(form, "Blocked Commands", agent.permissionRules.deny.join(", ")); - } - this.renderConfigRow(form, "Memory", agent.memory ? "Enabled" : "Disabled"); - this.renderConfigRow( - form, - "Auto-compact", - agent.autoCompactThreshold && agent.autoCompactThreshold > 0 - ? `at ${agent.autoCompactThreshold}% context` - : "disabled", - ); - if (agent.wikiReferences && agent.wikiReferences.length > 0) { - this.renderConfigRow( - form, - "Wiki access", - agent.wikiReferences.map((r) => r.agent).join(", "), - ); - } - this.renderConfigRow(form, "Tags", agent.tags.join(", ") || "none"); - - const promptSection = form.createDiv({ cls: "af-config-prompt-section" }); - promptSection.createDiv({ cls: "af-slideover-section-title", text: "SYSTEM PROMPT" }); - promptSection.createDiv({ cls: "af-output-block", text: agent.body || "(empty)" }); - - if (agent.heartbeatBody.trim()) { - const hbPromptSection = form.createDiv({ cls: "af-config-prompt-section" }); - hbPromptSection.createDiv({ cls: "af-slideover-section-title", text: "HEARTBEAT INSTRUCTION" }); - hbPromptSection.createDiv({ cls: "af-output-block", text: agent.heartbeatBody }); - } - - const actions = form.createDiv({ cls: "af-slideover-actions" }); - const editBtn = actions.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(editBtn, "edit", "af-btn-icon"); - editBtn.appendText(" Edit Agent"); - editBtn.onclick = () => this.navigate("edit-agent", agent.name); - } - private renderConfigRow(container: HTMLElement, label: string, value: string): void { const row = container.createDiv({ cls: "af-detail-row" }); row.createSpan({ cls: "af-detail-label", text: label }); row.createSpan({ cls: "af-detail-value af-mono", text: value }); } - private renderAgentRunsTab(container: HTMLElement, runs: RunLogData[]): void { - if (runs.length === 0) { - this.renderEmptyState(container, "scroll-text", "No runs yet", "Run this agent to see history"); - return; - } - - for (const run of runs) { - const item = container.createDiv({ cls: "af-run-list-item" }); - - const statusIcon = item.createDiv({ cls: `af-tl-icon ${this.statusToTimelineClass(run.status)}` }); - setIcon(statusIcon, this.statusToIconName(run.status)); - - const body = item.createDiv({ cls: "af-tl-body" }); - const titleRow = body.createDiv({ cls: "af-tl-title" }); - titleRow.createSpan({ text: run.task }); - titleRow.createSpan({ cls: `af-status-badge ${this.statusToBadgeClass(run.status)}`, text: this.statusToBadgeText(run.status) }); - - const meta = body.createDiv({ cls: "af-tl-meta" }); - meta.createSpan({ text: `${this.formatStarted(run.started)} \u00B7 ${this.formatDuration(run.durationSeconds)}` }); - if (run.tokensUsed) { - meta.createSpan({ text: `${run.tokensUsed.toLocaleString()} tokens` }); - } - - body.createDiv({ cls: "af-tl-desc", text: truncate(run.output, 120) }); - - item.onclick = () => this.openSlideover(run); - } - } - - private async renderAgentMemoryTab(container: HTMLElement, agent: AgentConfig): Promise { - if (!agent.memory) { - this.renderEmptyState(container, "file-text", "Memory disabled", "Enable memory in agent config"); - return; - } - - const wm = await this.plugin.repository.readWorkingMemory(agent.name); - - // Header: token usage vs budget + reflection status. - const meta = container.createDiv({ cls: "af-form-help" }); - const used = wm?.tokenEstimate ?? 0; - const reflectBits = agent.reflection.enabled - ? `reflection on (${agent.reflection.schedule})` - : "reflection off"; - meta.setText(`~${used} / ${agent.memoryTokenBudget} tokens · ${reflectBits}`); - - if (!wm || wm.sections.length === 0) { - this.renderEmptyState(container, "file-text", "No memories yet", "Agent will learn from runs"); - } else { - const block = container.createDiv({ cls: "af-output-block" }); - block.setText(renderSections(wm.sections)); - } - - const actions = container.createDiv({ cls: "af-slideover-actions" }); - - const reflectBtn = actions.createEl("button", { cls: "af-btn-sm" }); - createIcon(reflectBtn, "moon", "af-btn-icon"); - reflectBtn.appendText(" Reflect now"); - reflectBtn.onclick = async () => { - reflectBtn.disabled = true; - reflectBtn.setText(" Reflecting…"); - const res = await this.plugin.runtime.runReflectionNow(agent.name); - new Notice(`Agent Fleet: ${res.message}`); - await this.renderAgentMemoryTab((container.empty(), container), agent); - }; - - const editBtn = actions.createEl("button", { cls: "af-btn-sm" }); - createIcon(editBtn, "external-link", "af-btn-icon"); - editBtn.appendText(" Open in Editor"); - editBtn.onclick = () => - void this.plugin.openPath(this.plugin.repository.getWorkingMemoryPath(agent.name)); - } - private timeAgo(date: Date): string { const seconds = Math.round((Date.now() - date.getTime()) / 1000); if (seconds < 60) return "just now"; @@ -1612,7 +1199,7 @@ export class FleetDashboardView extends ItemView { const scheduleEl = footer.createSpan({ cls: "af-kanban-card-schedule" }); createIcon(scheduleEl, "loader-2", "af-meta-icon"); const elapsedSec = Math.round(elapsed); - scheduleEl.appendText(` ${elapsedSec}s / ${timeout}s`); + const elapsedText = scheduleEl.createSpan({ text: ` ${elapsedSec}s / ${timeout}s` }); // Stop button const stopBtn = footer.createEl("button", { cls: "af-kanban-stop-btn" }); @@ -1624,17 +1211,19 @@ export class FleetDashboardView extends ItemView { new Notice(`Stopped task "${task.taskId}"`); }; - // Live update every second - const interval = window.setInterval(() => { + // Live update every second via the shared running-card interval: only the + // stored text node and bar width are touched — no child rebuilds per tick. + this.runningCardTickers.push(() => { const now = (Date.now() - startTime) / 1000; const newPct = Math.min(95, (now / timeout) * 100); bar.setCssStyles({ width: `${newPct}%` }); - const nowSec = Math.round(now); - scheduleEl.textContent = ""; - setIcon(scheduleEl, "loader-2"); - scheduleEl.appendText(` ${nowSec}s / ${timeout}s`); - }, 1000); - this.streamingUnsubscribes.push(() => window.clearInterval(interval)); + elapsedText.setText(` ${Math.round(now)}s / ${timeout}s`); + }); + if (this.runningCardInterval === undefined) { + this.runningCardInterval = window.setInterval(() => { + for (const tick of this.runningCardTickers) tick(); + }, 1000); + } } private renderKanbanCompletedCard(container: HTMLElement, run: RunLogData): void { @@ -1693,672 +1282,13 @@ export class FleetDashboardView extends ItemView { card.onclick = () => this.openSlideover(run); } - // ═══════════════════════════════════════════════════════ - // Runs Page - // ═══════════════════════════════════════════════════════ - - private renderRunsPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-runs-page" }); - const runs = this.plugin.runtime.getRecentRuns(); - - const toolbar = page.createDiv({ cls: "af-runs-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "Run History" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - const tableWrap = page.createDiv({ cls: "af-runs-table" }); - - if (runs.length === 0) { - this.renderEmptyState(tableWrap, "scroll-text", "No runs yet", "Run an agent to see history here"); - return; - } - - const table = tableWrap.createEl("table"); - const thead = table.createEl("thead"); - const headerRow = thead.createEl("tr"); - for (const col of ["Status", "Agent", "Task", "Started", "Duration", "Tokens", "Model"]) { - headerRow.createEl("th", { text: col }); - } - - const tbody = table.createEl("tbody"); - for (const run of runs.slice(0, 50)) { - this.renderRunRow(tbody, run); - } - } - - private renderRunRow(tbody: HTMLElement, run: RunLogData): void { - const row = tbody.createEl("tr"); - - const statusTd = row.createEl("td"); - const badge = statusTd.createSpan({ - cls: `af-status-badge ${this.statusToBadgeClass(run.status)}`, - }); - const badgeIcon = badge.createSpan(); - setIcon(badgeIcon, this.statusToIconName(run.status)); - badge.appendText(` ${this.statusToBadgeText(run.status)}`); - - const agentTd = row.createEl("td", { cls: "af-agent-link" }); - agentTd.setText(run.agent); - agentTd.onclick = (e) => { - e.stopPropagation(); - this.navigate("agent-detail", run.agent); - }; - - row.createEl("td", { text: run.task }); - row.createEl("td", { cls: "af-mono", text: this.formatStarted(run.started) }); - row.createEl("td", { cls: "af-mono", text: this.formatDuration(run.durationSeconds) }); - row.createEl("td", { - cls: "af-mono", - text: run.tokensUsed ? run.tokensUsed.toLocaleString() : "\u2014", - }); - row.createEl("td", { cls: "af-mono", text: run.model }); - - row.setCssStyles({ cursor: "pointer" }); - row.onclick = () => this.openSlideover(run); - } - - // ═══════════════════════════════════════════════════════ - // Skills Page - // ═══════════════════════════════════════════════════════ - - private renderSkillsPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-skills-page" }); - const snapshot = this.plugin.runtime.getSnapshot(); - - const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "Skills Library" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(newBtn, "plus", "af-btn-icon"); - newBtn.appendText(" New Skill"); - newBtn.onclick = () => void this.plugin.createSkillTemplate(); - - const grid = page.createDiv({ cls: "af-skills-grid" }); - - if (snapshot.skills.length === 0) { - this.renderEmptyState(grid, "puzzle", "No skills yet", "Create skills to give agents specialized abilities"); - return; - } - - for (const skill of snapshot.skills) { - this.renderSkillCard(grid, skill, snapshot.agents); - } - } - - private renderSkillCard(container: HTMLElement, skill: SkillConfig, agents: AgentConfig[]): void { - const card = container.createDiv({ cls: "af-skill-card" }); - - const cardHeader = card.createDiv({ cls: "af-skill-card-header" }); - const iconEl = cardHeader.createDiv({ cls: "af-skill-card-icon" }); - setIcon(iconEl, this.getSkillIcon(skill.name)); - - const skillEditBtn = cardHeader.createEl("button", { cls: "af-btn-sm af-btn-xs" }); - createIcon(skillEditBtn, "edit", "af-btn-icon"); - skillEditBtn.onclick = (e) => { - e.stopPropagation(); - this.navigate("edit-skill", skill.name); - }; - - card.createDiv({ cls: "af-skill-card-name", text: skill.name }); - card.createDiv({ cls: "af-skill-card-desc", text: skill.description ?? "No description" }); - - const usedBy = agents.filter((a) => a.skills.includes(skill.name)); - if (usedBy.length > 0) { - const agentsRow = card.createDiv({ cls: "af-skill-card-agents" }); - for (const agent of usedBy) { - agentsRow.createSpan({ cls: "af-skill-card-agent-tag", text: agent.name }); - } - } - - // No card-level click — skills have no detail page. Editing is via the edit button. - } - - // ═══════════════════════════════════════════════════════ - // Channels Page - // ═══════════════════════════════════════════════════════ - - private renderChannelsPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-agents-page" }); - const snapshot = this.plugin.runtime.getSnapshot(); - - const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "Channels" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(newBtn, "plus", "af-btn-icon"); - newBtn.appendText(" New Channel"); - newBtn.onclick = () => this.navigate("create-channel"); - - const grid = page.createDiv({ cls: "af-agents-grid" }); - - if (snapshot.channels.length === 0) { - this.renderEmptyState( - grid, - "radio", - "No channels configured", - "Connect an agent to Slack or another chat platform", - ); - return; - } - - for (const channel of snapshot.channels) { - this.renderChannelCard(grid, channel, snapshot.validationIssues); - } - } - - /** - * Wiki Keepers page: lists every Wiki Keeper instance with the latest - * lint report parsed from its scope's `log.md`. "Needs review" items - * render as cards with a Dismiss button (in-memory only — re-parse on - * navigation gives the user a fresh view of the most recent lint pass). - */ - private async renderWikiKeepersPage(container: HTMLElement): Promise { - const page = container.createDiv({ cls: "af-agents-page" }); - const snapshot = this.plugin.runtime.getSnapshot(); - - const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "Wiki Keepers" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - const keepers = snapshot.agents.filter( - (a): a is AgentConfig & { wikiKeeper: NonNullable } => - a.wikiKeeper !== undefined, - ); - - if (keepers.length === 0) { - this.renderEmptyState( - page, - "library", - "No Wiki Keepers yet", - "Open Settings → Agent Fleet → Wiki Keepers → + Add to create one.", - ); - return; - } - - const list = page.createDiv({ cls: "af-wk-list" }); - list.setCssStyles({ display: "flex" }); - list.setCssStyles({ flexDirection: "column" }); - list.setCssStyles({ gap: "16px" }); - - for (const keeper of keepers) { - await this.renderWikiKeeperCard(list, keeper); - } - } - - private async renderWikiKeeperCard( - container: HTMLElement, - agent: AgentConfig & { wikiKeeper: NonNullable }, - ): Promise { - const wk = agent.wikiKeeper; - const card = container.createDiv({ cls: "af-card" }); - card.setCssStyles({ padding: "16px" }); - card.setCssStyles({ border: "1px solid var(--background-modifier-border)" }); - card.setCssStyles({ borderRadius: "8px" }); - - const header = card.createDiv(); - header.setCssStyles({ display: "flex" }); - header.setCssStyles({ alignItems: "center" }); - header.setCssStyles({ gap: "12px" }); - header.setCssStyles({ marginBottom: "12px" }); - const titleWrap = header.createDiv(); - titleWrap.setCssStyles({ flex: "1" }); - titleWrap.createEl("strong", { text: agent.name }); - const scopeLabel = wk.scopeRoot || "(whole vault)"; - titleWrap.createEl("div", { - text: `Scope: ${scopeLabel} · topics: ${wk.topicsRoot}/ · log: ${wk.logPath}`, - cls: "af-form-hint", - }); - - const openBtn = header.createEl("button", { cls: "af-btn-sm" }); - openBtn.appendText("Open log"); - openBtn.onclick = () => { - const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath; - const file = this.plugin.app.vault.getAbstractFileByPath(logFullPath); - if (file instanceof TFile) { - void this.plugin.app.workspace.getLeaf().openFile(file); - } else { - new Notice(`Log file not found: ${logFullPath}`); - } - }; - - // Read and parse log.md - const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath; - const logFile = this.plugin.app.vault.getAbstractFileByPath(logFullPath); - let report: ReturnType = null; - if (logFile instanceof TFile) { - try { - const content = await this.plugin.app.vault.cachedRead(logFile); - report = parseLatestLintReport(content); - } catch { - report = null; - } - } - - if (!report) { - const empty = card.createDiv({ cls: "af-form-hint" }); - empty.setText( - "No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.", - ); - return; - } - - // Latest lint header - const reportHeader = card.createDiv(); - reportHeader.setCssStyles({ display: "flex" }); - reportHeader.setCssStyles({ alignItems: "baseline" }); - reportHeader.setCssStyles({ gap: "12px" }); - reportHeader.setCssStyles({ marginBottom: "8px" }); - reportHeader.createEl("strong", { text: `Lint ${report.date}` }); - reportHeader.createSpan({ - cls: "af-form-hint", - text: `${report.summary.length} summary lines · ${report.autoApplied.length} auto-applied · ${report.needsReview.length} needs review`, - }); - - // Summary bullets (read-only) - if (report.summary.length > 0) { - const sumDetails = card.createEl("details"); - sumDetails.createEl("summary", { text: "Summary" }); - const sumList = sumDetails.createEl("ul"); - sumList.setCssStyles({ marginTop: "4px" }); - for (const item of report.summary) { - sumList.createEl("li", { text: item }); - } - } - - if (report.autoApplied.length > 0) { - const autoDetails = card.createEl("details"); - autoDetails.createEl("summary", { text: `Auto-applied (${report.autoApplied.length})` }); - const autoList = autoDetails.createEl("ul"); - autoList.setCssStyles({ marginTop: "4px" }); - for (const item of report.autoApplied) { - autoList.createEl("li", { text: item }); - } - } - - if (report.refreshChained.length > 0) { - const refDetails = card.createEl("details"); - refDetails.createEl("summary", { - text: `Refresh chained (${report.refreshChained.length})`, - }); - const refList = refDetails.createEl("ul"); - refList.setCssStyles({ marginTop: "4px" }); - for (const item of report.refreshChained) { - refList.createEl("li", { text: item }); - } - } - - // Needs review queue — the actionable bit - const reviewWrap = card.createDiv(); - reviewWrap.setCssStyles({ marginTop: "12px" }); - reviewWrap.createEl("strong", { text: `Needs review (${report.needsReview.length})` }); - - if (report.needsReview.length === 0) { - reviewWrap.createDiv({ - cls: "af-form-hint", - text: "All clear. Nothing requires manual review from this lint pass.", - }); - return; - } - - const reviewList = reviewWrap.createDiv(); - reviewList.setCssStyles({ display: "flex" }); - reviewList.setCssStyles({ flexDirection: "column" }); - reviewList.setCssStyles({ gap: "6px" }); - reviewList.setCssStyles({ marginTop: "8px" }); - - for (const item of report.needsReview) { - const row = reviewList.createDiv(); - row.setCssStyles({ display: "flex" }); - row.setCssStyles({ alignItems: "flex-start" }); - row.setCssStyles({ gap: "8px" }); - row.setCssStyles({ padding: "8px 10px" }); - row.setCssStyles({ background: "var(--background-secondary)" }); - row.setCssStyles({ borderRadius: "4px" }); - row.setCssStyles({ fontSize: "13px" }); - - const text = row.createDiv(); - text.setCssStyles({ flex: "1" }); - text.setText(item); - - const dismissBtn = row.createEl("button", { cls: "af-btn-sm", text: "Dismiss" }); - dismissBtn.title = - "Hide this item from the dashboard until the next lint pass (does not modify log.md)."; - dismissBtn.onclick = () => { - row.remove(); - }; - } - } - - private renderChannelCard( - container: HTMLElement, - channel: ChannelConfig, - validationIssues: Array<{ path: string; message: string }>, - ): void { - const status = this.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled"; - const avatarCls = channelStatusToAvatarClass(status); - const cardCls = channel.enabled && status !== "disabled" ? "af-agent-card" : "af-agent-card disabled"; - - const card = container.createDiv({ cls: cardCls }); - card.setCssStyles({ cursor: "default" }); // No card-level click — channels have no detail page - - // Header — avatar (icon + status color) + name/agent + type pill - const header = card.createDiv({ cls: "af-agent-card-header" }); - const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` }); - setIcon(avatar, "radio"); - - const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" }); - titleBlock.createDiv({ cls: "af-agent-card-name", text: channel.name }); - titleBlock.createDiv({ - cls: "af-agent-card-desc", - text: `Default: ${channel.defaultAgent}`, - }); - - const statusPill = header.createSpan({ cls: `af-pill ${channelStatusPillColor(status)}` }); - statusPill.createSpan({ cls: "af-dot" }); - statusPill.appendText(` ${status}`); - - // Agents — show all allowed agents as tags (same style as skill tags on agent cards) - if (channel.allowedAgents.length > 0) { - const agentsRow = card.createDiv({ cls: "af-agent-card-skills" }); - for (const name of channel.allowedAgents) { - const tag = agentsRow.createSpan({ cls: "af-skill-tag", text: name }); - if (name === channel.defaultAgent) { - tag.setCssStyles({ fontWeight: "700" }); - } - } - } - - // Stats — sessions, messages in/out, allowlist - const stats = card.createDiv({ cls: "af-agent-card-stats" }); - const sessionCount = this.plugin.channelManager?.getSessionCount(channel.name) ?? 0; - const metrics = this.plugin.channelManager?.getMetrics(channel.name); - const agentCount = channel.allowedAgents.length > 0 - ? String(channel.allowedAgents.length) - : "all"; - - this.renderAgentStat(stats, agentCount, "Agents"); - this.renderAgentStat(stats, String(sessionCount), "Sessions"); - this.renderAgentStat(stats, String(metrics?.messagesReceived ?? 0), "In"); - this.renderAgentStat(stats, String(metrics?.messagesSent ?? 0), "Out"); - - // Footer — meta text + edit action - const footer = card.createDiv({ cls: "af-agent-card-footer" }); - const metaParts: string[] = [channel.type]; - if (!channel.enabled) metaParts.push("disabled"); - if (channel.allowedUsers.length > 0) metaParts.push(`${channel.allowedUsers.length} user(s)`); - else metaParts.push("allowlist empty"); - footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") }); - - const actions = footer.createDiv({ cls: "af-agent-card-actions" }); - const editBtn = actions.createEl("button", { cls: "af-btn-sm" }); - createIcon(editBtn, "edit", "af-btn-icon"); - editBtn.appendText(" Edit"); - editBtn.onclick = (e) => { - e.stopPropagation(); - this.navigate("edit-channel", channel.name); - }; - - // Validation issues — red-tinted block below the footer - const issues = validationIssues.filter((i) => i.path === channel.filePath); - if (issues.length > 0) { - const issuesBox = card.createDiv({ cls: "af-channel-issues" }); - for (const issue of issues) { - issuesBox.createDiv({ cls: "af-channel-issue-row", text: issue.message }); - } - } - - // No card-level click — channels have no detail page. Editing is via the edit button. - } - // ═══════════════════════════════════════════════════════ // Create Channel Page // ═══════════════════════════════════════════════════════ private renderCreateChannelPage(container: HTMLElement): void { const page = container.createDiv({ cls: "af-create-agent-page" }); - const snapshot = this.plugin.runtime.getSnapshot(); - const credentials = this.plugin.channelCredentials.list(); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatar, "plus"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Channel" }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Connect an external chat transport to an agent" }); - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - - // Form state - const state = { - name: "", - type: "slack", - defaultAgent: snapshot.agents[0]?.name ?? "", - allowedAgents: [] as string[], - credentialRef: credentials[0]?.ref ?? "", - allowedUsers: "", - perUserSessions: true, - channelContext: "", - enabled: true, - tags: "", - body: "", - transportJson: "", - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Channel Details ─── - const detailsSection = form.createDiv({ cls: "af-create-section" }); - const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); - const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(detailsIcon, "radio"); - detailsHeader.createSpan({ text: "Channel Details" }); - - this.createFormField(detailsSection, "Name", "my-slack", "Unique identifier for this channel", (v) => { state.name = v; }); - - // Type dropdown — only Slack is supported in v1; others shown as disabled - const typeRow = detailsSection.createDiv({ cls: "af-form-row" }); - typeRow.createDiv({ cls: "af-form-label", text: "Type" }); - const typeSelect = typeRow.createEl("select", { cls: "af-form-select" }); - typeSelect.createEl("option", { text: "slack", attr: { value: "slack" } }); - typeSelect.createEl("option", { text: "telegram", attr: { value: "telegram" } }); - typeSelect.createEl("option", { text: "discord", attr: { value: "discord" } }); - typeSelect.addEventListener("change", () => { state.type = typeSelect.value; }); - - // Credential dropdown - const credRow = detailsSection.createDiv({ cls: "af-form-row" }); - const credLabel = credRow.createDiv({ cls: "af-form-label" }); - credLabel.setText("Credential"); - this.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials"); - const credSelect = credRow.createEl("select", { cls: "af-form-select" }); - if (credentials.length === 0) { - credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } }); - } - for (const c of credentials) { - credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } }); - } - credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; }); - - // Enabled toggle - const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" }); - enabledToggle.onclick = () => { - const isOn = enabledToggle.hasClass("on"); - enabledToggle.toggleClass("on", !isOn); - state.enabled = !isOn; - }; - - // ─── Agent Routing ─── - const agentSection = form.createDiv({ cls: "af-create-section" }); - const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" }); - const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(agentIcon, "bot"); - agentHeader.createSpan({ text: "Agent Routing" }); - - // Default agent dropdown - const defaultRow = agentSection.createDiv({ cls: "af-form-row" }); - const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" }); - defaultLabel.setText("Default agent"); - this.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message"); - const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" }); - for (const a of snapshot.agents) { - defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } }); - } - defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; }); - - // Allowed agents checkboxes - const allowedRow = agentSection.createDiv({ cls: "af-form-row" }); - const allowedLabel = allowedRow.createDiv({ cls: "af-form-label" }); - allowedLabel.setText("Allowed agents"); - this.addTooltip(allowedLabel, "Agents reachable via @prefix. Leave unchecked to allow all agents."); - const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" }); - for (const a of snapshot.agents) { - const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" }); - const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } }); - label.appendText(` ${a.name}`); - cb.addEventListener("change", () => { - if (cb.checked) { - if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name); - } else { - state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name); - } - }); - } - - // Per-user sessions toggle - const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" }); - perUserLabel.setText("Per-user sessions"); - this.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session"); - const perUserToggle = perUserRow.createDiv({ cls: "af-agent-card-toggle on" }); - perUserToggle.onclick = () => { - const isOn = perUserToggle.hasClass("on"); - perUserToggle.toggleClass("on", !isOn); - state.perUserSessions = !isOn; - }; - - // ─── Access Control ─── - const accessSection = form.createDiv({ cls: "af-create-section" }); - const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" }); - const accessIcon = accessHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(accessIcon, "shield-check"); - accessHeader.createSpan({ text: "Access Control" }); - - const usersLabel = accessSection.createDiv({ cls: "af-form-label" }); - usersLabel.setText("Allowed users"); - this.addTooltip(usersLabel, "User IDs, one per line — Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot."); - const usersTextarea = accessSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" }, - }); - usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; }); - - // ─── Channel Context ─── - const contextSection = form.createDiv({ cls: "af-create-section" }); - const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); - const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(contextIcon, "message-square"); - const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" }); - this.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel"); - const contextTextarea = contextSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" }, - }); - contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; }); - - this.createFormField(detailsSection, "Tags", "ops, internal", "Comma-separated metadata", (v) => { state.tags = v; }); - - // ─── Advanced ─── - const advSection = form.createDiv({ cls: "af-create-section" }); - const advHeader = advSection.createDiv({ cls: "af-create-section-header" }); - const advIcon = advHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(advIcon, "settings"); - const advHeaderLabel = advHeader.createSpan({ text: "Advanced" }); - this.addTooltip(advHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides"); - - const bodyLabel = advSection.createDiv({ cls: "af-form-label" }); - bodyLabel.setText("Body (markdown)"); - this.addTooltip(bodyLabel, "Free-form notes for this channel. Shown in the channel detail page; not sent to the agent."); - const bodyTextarea = advSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" }, - }); - bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; }); - - const transportLabel = advSection.createDiv({ cls: "af-form-label" }); - transportLabel.setText("Transport config (JSON)"); - this.addTooltip(transportLabel, "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults."); - const transportTextarea = advSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" }, - }); - transportTextarea.addEventListener("input", () => { state.transportJson = transportTextarea.value; }); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("channels"); - - const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(createBtn, "plus", "af-btn-icon"); - createBtn.appendText(" Create Channel"); - createBtn.onclick = async () => { - const name = state.name.trim(); - if (!name) { new Notice("Channel name is required."); return; } - if (!state.credentialRef) { new Notice("Select a credential."); return; } - - let transport: Record | undefined; - if (state.transportJson.trim()) { - try { - const parsed: unknown = JSON.parse(state.transportJson); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - transport = parsed as Record; - } else { - new Notice("Transport config must be a JSON object."); - return; - } - } catch (err) { - new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`); - return; - } - } - - const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean); - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - const frontmatter: Record = { - name: slugify(name), - type: state.type, - default_agent: state.defaultAgent, - allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : undefined, - enabled: state.enabled, - credential_ref: state.credentialRef, - allowed_users: parseUsers(state.allowedUsers), - per_user_sessions: state.perUserSessions, - channel_context: state.channelContext.trim() || undefined, - tags: parseTags(state.tags).length > 0 ? parseTags(state.tags) : undefined, - transport, - }; - - try { - const channelSlug = slugify(name); - const path = await this.plugin.repository.getAvailablePath( - this.plugin.repository.getSubfolder("channels"), - channelSlug, - ); - await this.plugin.app.vault.create( - path, - stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim()), - ); - new Notice(`Channel "${channelSlug}" created.`); - await this.plugin.refreshFromVault(); - this.navigate("edit-channel", channelSlug); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to create channel: ${msg}`); - } - }; + renderChannelForm(page, this.channelFormDeps()); } // ═══════════════════════════════════════════════════════ @@ -2379,468 +1309,188 @@ export class FleetDashboardView extends ItemView { return; } - const snapshot = this.plugin.runtime.getSnapshot(); - const credentials = this.plugin.channelCredentials.list(); - const status = this.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled"; + renderChannelForm(page, this.channelFormDeps(), channel); + } - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatarEl = headerLeft.createDiv({ cls: `af-agent-card-avatar ${channelStatusToAvatarClass(status)}` }); - setIcon(avatarEl, "radio"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Channel: ${channel.name}` }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: `Status: ${status}` }); - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - - // Form state pre-filled - const state = { - type: channel.type, - defaultAgent: channel.defaultAgent, - allowedAgents: [...channel.allowedAgents], - credentialRef: channel.credentialRef, - allowedUsers: channel.allowedUsers.join("\n"), - perUserSessions: channel.perUserSessions, - channelContext: channel.channelContext, - enabled: channel.enabled, - tags: channel.tags.join(", "), - body: channel.body, - transportJson: Object.keys(channel.transport).length > 0 - ? JSON.stringify(channel.transport, null, 2) - : "", - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Channel Details ─── - const detailsSection = form.createDiv({ cls: "af-create-section" }); - const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); - const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(detailsIcon, "radio"); - detailsHeader.createSpan({ text: "Channel Details" }); - - // Name (read-only) - const nameRow = detailsSection.createDiv({ cls: "af-form-row" }); - nameRow.createDiv({ cls: "af-form-label", text: "Name" }); - const nameInput = nameRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", value: channel.name, disabled: "true" }, - }); - nameInput.setCssStyles({ opacity: "0.6" }); - - // Type dropdown — only Slack is supported in v1; others shown as disabled - const typeRow = detailsSection.createDiv({ cls: "af-form-row" }); - typeRow.createDiv({ cls: "af-form-label", text: "Type" }); - const typeSelect = typeRow.createEl("select", { cls: "af-form-select" }); - for (const t of ["slack", "telegram", "discord"] as const) { - const opt = typeSelect.createEl("option", { text: t, attr: { value: t } }); - if (t === channel.type) opt.selected = true; - } - typeSelect.addEventListener("change", () => { state.type = typeSelect.value as ChannelConfig["type"]; }); - - // Credential dropdown - const credRow = detailsSection.createDiv({ cls: "af-form-row" }); - const credLabel = credRow.createDiv({ cls: "af-form-label" }); - credLabel.setText("Credential"); - this.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials"); - const credSelect = credRow.createEl("select", { cls: "af-form-select" }); - if (credentials.length === 0) { - credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } }); - } - for (const c of credentials) { - const opt = credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } }); - if (c.ref === channel.credentialRef) opt.selected = true; - } - credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; }); - - // Enabled toggle - const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${channel.enabled ? " on" : ""}` }); - enabledToggle.onclick = () => { - const isOn = enabledToggle.hasClass("on"); - enabledToggle.toggleClass("on", !isOn); - state.enabled = !isOn; - }; - - // ─── Agent Routing ─── - const agentSection = form.createDiv({ cls: "af-create-section" }); - const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" }); - const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(agentIcon, "bot"); - agentHeader.createSpan({ text: "Agent Routing" }); - - // Default agent dropdown - const defaultRow = agentSection.createDiv({ cls: "af-form-row" }); - const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" }); - defaultLabel.setText("Default agent"); - this.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message"); - const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" }); - for (const a of snapshot.agents) { - const opt = defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } }); - if (a.name === channel.defaultAgent) opt.selected = true; - } - defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; }); - - // Allowed agents checkboxes - const allowedRow = agentSection.createDiv({ cls: "af-form-row" }); - const allowedLabelEl = allowedRow.createDiv({ cls: "af-form-label" }); - allowedLabelEl.setText("Allowed agents"); - this.addTooltip(allowedLabelEl, "Agents reachable via @prefix. Leave unchecked to allow all agents."); - const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" }); - for (const a of snapshot.agents) { - const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" }); - const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } }); - if (channel.allowedAgents.includes(a.name)) cb.checked = true; - label.appendText(` ${a.name}`); - cb.addEventListener("change", () => { - if (cb.checked) { - if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name); - } else { - state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name); - } - }); - } - - // Per-user sessions toggle - const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" }); - perUserLabel.setText("Per-user sessions"); - this.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session"); - const perUserToggle = perUserRow.createDiv({ cls: `af-agent-card-toggle${channel.perUserSessions ? " on" : ""}` }); - perUserToggle.onclick = () => { - const isOn = perUserToggle.hasClass("on"); - perUserToggle.toggleClass("on", !isOn); - state.perUserSessions = !isOn; - }; - - // ─── Access Control ─── - const accessSection = form.createDiv({ cls: "af-create-section" }); - const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" }); - const accessIconEl = accessHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(accessIconEl, "shield-check"); - accessHeader.createSpan({ text: "Access Control" }); - - const usersLabelEl = accessSection.createDiv({ cls: "af-form-label" }); - usersLabelEl.setText("Allowed users"); - this.addTooltip(usersLabelEl, "Slack user IDs (U...), one per line. Only listed users can reach the bot."); - const usersTextarea = accessSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" }, - }); - usersTextarea.value = channel.allowedUsers.join("\n"); - usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; }); - - // ─── Channel Context ─── - const contextSection = form.createDiv({ cls: "af-create-section" }); - const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); - const contextIconEl = contextHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(contextIconEl, "message-square"); - const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" }); - this.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel"); - const contextTextarea = contextSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" }, - }); - contextTextarea.value = channel.channelContext; - contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; }); - - this.createFormField(detailsSection, "Tags", "ops, internal", "Comma-separated metadata", (v) => { state.tags = v; }, channel.tags.join(", ")); - - // ─── Advanced ─── - const editAdvSection = form.createDiv({ cls: "af-create-section" }); - const editAdvHeader = editAdvSection.createDiv({ cls: "af-create-section-header" }); - const editAdvIcon = editAdvHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(editAdvIcon, "settings"); - const editAdvHeaderLabel = editAdvHeader.createSpan({ text: "Advanced" }); - this.addTooltip(editAdvHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides"); - - const editBodyLabel = editAdvSection.createDiv({ cls: "af-form-label" }); - editBodyLabel.setText("Body (markdown)"); - const editBodyTextarea = editAdvSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" }, - }); - editBodyTextarea.value = channel.body; - editBodyTextarea.addEventListener("input", () => { state.body = editBodyTextarea.value; }); - - const editTransportLabel = editAdvSection.createDiv({ cls: "af-form-label" }); - editTransportLabel.setText("Transport config (JSON)"); - this.addTooltip(editTransportLabel, "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults."); - const editTransportTextarea = editAdvSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" }, - }); - editTransportTextarea.value = state.transportJson; - editTransportTextarea.addEventListener("input", () => { state.transportJson = editTransportTextarea.value; }); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - - const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); - createIcon(deleteBtn, "trash-2", "af-btn-icon"); - deleteBtn.appendText(" Delete"); - deleteBtn.onclick = async () => { - await this.plugin.repository.deleteChannel(channel.name); - new Notice(`Channel "${channel.name}" deleted.`); - await new Promise((r) => window.setTimeout(r, 200)); - await this.plugin.refreshFromVault(); - this.navigate("channels"); - }; - - footer.createDiv({ cls: "af-toolbar-spacer" }); - - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("channels"); - - const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(saveBtn, "check", "af-btn-icon"); - saveBtn.appendText(" Save Changes"); - saveBtn.onclick = async () => { - const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean); - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - let transport: Record | undefined; - if (state.transportJson.trim()) { - try { - const parsed: unknown = JSON.parse(state.transportJson); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - transport = parsed as Record; - } else { - new Notice("Transport config must be a JSON object."); - return; - } - } catch (err) { - new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`); - return; - } - } else { - transport = {}; - } - try { - await this.plugin.repository.updateChannel(channel.name, { - type: state.type, - default_agent: state.defaultAgent, - allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : [], - enabled: state.enabled, - credential_ref: state.credentialRef, - allowed_users: parseUsers(state.allowedUsers), - per_user_sessions: state.perUserSessions, - channel_context: state.channelContext.trim(), - tags: parseTags(state.tags), - body: state.body.trim(), - transport, - }); - new Notice(`Channel "${channel.name}" updated.`); - await this.plugin.refreshFromVault(); - this.navigate("channels"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to update channel: ${msg}`); - } + /** View helpers handed to the shared channel form (src/views/forms/channelForm.ts). */ + private channelFormDeps(): ChannelFormDeps { + return { + plugin: this.plugin, + navigate: (page, context) => this.navigate(page, context), + addTooltip: (labelEl, text) => this.addTooltip(labelEl, text), + createFormField: (container, label, placeholder, desc, onChange, initialValue) => + this.createFormField(container, label, placeholder, desc, onChange, initialValue), + markSubmitBusy: (btn, busyLabel, iconName, label) => + this.markSubmitBusy(btn, busyLabel, iconName, label), }; } - // ═══════════════════════════════════════════════════════ - // Approvals Page - // ═══════════════════════════════════════════════════════ - - private renderApprovalsPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-approvals-page" }); - const runs = this.plugin.runtime.getRecentRuns(); - - const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "Approvals" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - // Pending approvals - const pendingRuns = runs.filter((r) => - (r.approvals ?? []).some((a) => a.status === "pending"), - ); - - if (pendingRuns.length > 0) { - const pendingSection = page.createDiv({ cls: "af-section-card" }); - const pendingHeader = pendingSection.createDiv({ cls: "af-section-header" }); - const pendingTitle = pendingHeader.createDiv({ cls: "af-section-title" }); - createIcon(pendingTitle, "alert-triangle"); - pendingTitle.appendText(` Pending (${pendingRuns.length})`); - - const pendingBody = pendingSection.createDiv({ cls: "af-approvals-list" }); - for (const run of pendingRuns) { - this.renderApprovalItem(pendingBody, run, true); - } - } else { - const emptySection = page.createDiv({ cls: "af-section-card" }); - this.renderEmptyState(emptySection, "shield-check", "No pending approvals", "All clear!"); - } - - // Resolved approvals history - const resolvedRuns = runs.filter((r) => - (r.approvals ?? []).some((a) => a.status !== "pending"), - ); - - if (resolvedRuns.length > 0) { - const resolvedSection = page.createDiv({ cls: "af-section-card" }); - const resolvedHeader = resolvedSection.createDiv({ cls: "af-section-header" }); - const resolvedTitle = resolvedHeader.createDiv({ cls: "af-section-title" }); - createIcon(resolvedTitle, "check-circle-2"); - resolvedTitle.appendText(" History"); - - const resolvedBody = resolvedSection.createDiv({ cls: "af-approvals-list" }); - for (const run of resolvedRuns.slice(0, 20)) { - this.renderApprovalItem(resolvedBody, run, false); - } - } + /** Shared subset of view helpers handed to every extracted form module + * (src/views/forms/). Page-specific deps spread this and add their own + * narrowly-typed navigate delegate. */ + private baseFormDeps(): DashboardFormDeps { + return { + plugin: this.plugin, + addTooltip: (labelEl, text) => this.addTooltip(labelEl, text), + createFormField: (container, label, placeholder, desc, onChange, initialValue) => + this.createFormField(container, label, placeholder, desc, onChange, initialValue), + markSubmitBusy: (btn, busyLabel, iconName, label) => + this.markSubmitBusy(btn, busyLabel, iconName, label), + }; } - private renderApprovalItem(container: HTMLElement, run: RunLogData, showActions: boolean): void { - for (const approval of run.approvals ?? []) { - if (showActions && approval.status !== "pending") continue; - if (!showActions && approval.status === "pending") continue; - - const item = container.createDiv({ cls: "af-approval-item" }); - - const iconEl = item.createDiv({ cls: "af-approval-item-icon" }); - if (approval.status === "pending") { - setIcon(iconEl, "shield-check"); - iconEl.addClass("pending"); - } else if (approval.status === "approved") { - setIcon(iconEl, "check-circle-2"); - iconEl.addClass("approved"); - } else { - setIcon(iconEl, "x-circle"); - iconEl.addClass("rejected"); - } - - const body = item.createDiv({ cls: "af-approval-item-body" }); - body.createDiv({ - cls: "af-approval-item-title", - text: `${run.agent} \u2192 ${approval.tool}`, - }); - body.createDiv({ - cls: "af-approval-item-meta", - text: `Task: ${run.task} \u00B7 ${approval.command ?? "no command"} \u00B7 ${this.formatStarted(run.started)}`, - }); - if (approval.reason) { - body.createDiv({ cls: "af-approval-item-reason", text: `Reason: ${approval.reason}` }); - } - - if (showActions && approval.status === "pending") { - const actions = item.createDiv({ cls: "af-approval-item-actions" }); - const approveBtn = actions.createEl("button", { cls: "af-btn-approve" }); - createIcon(approveBtn, "check-circle-2", "af-btn-icon"); - approveBtn.appendText(" Approve"); - approveBtn.onclick = () => - void this.plugin.runtime - .resolveApproval(run, approval.tool, "approved") - .then(() => this.render()); - - const rejectBtn = actions.createEl("button", { cls: "af-btn-reject" }); - createIcon(rejectBtn, "x-circle", "af-btn-icon"); - rejectBtn.appendText(" Reject"); - rejectBtn.onclick = () => - void this.plugin.runtime - .resolveApproval(run, approval.tool, "rejected") - .then(() => this.render()); - } - } + /** View helpers handed to the shared agent forms (src/views/forms/agentForm.ts). */ + private agentFormDeps(): AgentFormDeps { + return { + ...this.baseFormDeps(), + navigate: (page, context) => this.navigate(page, context), + renderAgentAvatar: (el, agent) => this.renderAgentAvatar(el, agent), + }; } - // ═══════════════════════════════════════════════════════ - // Task Detail Page - // ═══════════════════════════════════════════════════════ + /** View helpers handed to the shared task forms (src/views/forms/taskForm.ts). */ + private taskFormDeps(): TaskFormDeps { + return { + ...this.baseFormDeps(), + navigate: (page, context) => this.navigate(page, context), + }; + } - private renderTaskDetailPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-task-detail-page" }); - const taskId = this.detailContext; - if (!taskId) { - this.renderEmptyState(page, "circle-dot", "No task selected", ""); - return; - } + /** View helpers handed to the shared skill form (src/views/forms/skillForm.ts). */ + private skillFormDeps(): SkillFormDeps { + return { + ...this.baseFormDeps(), + navigate: (page) => this.navigate(page), + }; + } - const task = this.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId); - if (!task) { - this.renderEmptyState(page, "circle-dot", "Task not found", `Task "${taskId}" was not found`); - return; - } + /** View helpers handed to the MCP server form (src/views/forms/mcpForm.ts). */ + private mcpFormDeps(): McpFormDeps { + return { + ...this.baseFormDeps(), + navigate: (page) => this.navigate(page), + }; + } - const snapshot = this.plugin.runtime.getSnapshot(); - const runs = this.plugin.runtime.getRecentRuns().filter((r) => r.task === taskId); - const agent = snapshot.agents.find((a) => a.name === task.agent); + /** Shared subset of view helpers handed to every extracted page module + * (src/views/pages/). Page-specific deps spread this and add their own + * narrowly-typed delegates. */ + private basePageDeps(): DashboardPageDeps { + return { + plugin: this.plugin, + renderEmptyState: (container, iconName, label, sublabel, action) => + this.renderEmptyState(container, iconName, label, sublabel, action), + }; + } - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(taskIcon, "circle-dot"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: task.taskId }); - headerInfo.createDiv({ - cls: "af-detail-header-desc", - text: `Agent: ${task.agent}`, - }); + /** View helpers handed to the agents page (src/views/pages/agentsPage.ts). */ + private agentsPageDeps(): AgentsPageDeps { + return { + ...this.basePageDeps(), + navigate: (page, context) => this.navigate(page, context), + healthToClass: (status) => this.healthToClass(status), + renderAgentAvatar: (el, agent) => this.renderAgentAvatar(el, agent), + renderAgentStat: (container, value, label) => this.renderAgentStat(container, value, label), + formatTokenCount: (tokens) => formatTokenCount(tokens), + cronToHuman: (cron) => cronToHuman(cron), + timeUntil: (date) => this.timeUntil(date), + }; + } - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); + /** View helpers handed to the run-history page (src/views/pages/runsPage.ts). */ + private runsPageDeps(): RunsPageDeps { + return { + ...this.basePageDeps(), + navigate: (page, context) => this.navigate(page, context), + openSlideover: (run) => this.openSlideover(run), + formatStarted: (iso) => this.formatStarted(iso), + formatDuration: (seconds) => this.formatDuration(seconds), + statusToBadgeClass: (status) => this.statusToBadgeClass(status), + statusToIconName: (status) => this.statusToIconName(status), + statusToBadgeText: (status) => this.statusToBadgeText(status), + }; + } - const editBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); - createIcon(editBtn, "edit", "af-btn-icon"); - editBtn.appendText(" Edit"); - editBtn.onclick = () => this.navigate("edit-task", task.taskId); + /** View helpers handed to the skills-library page (src/views/pages/skillsPage.ts). */ + private skillsPageDeps(): SkillsPageDeps { + return { + ...this.basePageDeps(), + navigate: (page, context) => this.navigate(page, context), + }; + } - const runBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(runBtn, "play", "af-btn-icon"); - runBtn.appendText(" Run Now"); - runBtn.onclick = () => void this.plugin.runtime.runTaskNow(task); + /** View helpers handed to the approvals page (src/views/pages/approvalsPage.ts). */ + private approvalsPageDeps(): ApprovalsPageDeps { + return { + ...this.basePageDeps(), + formatStarted: (iso) => this.formatStarted(iso), + rerender: () => this.render(), + }; + } - // Details section - const details = page.createDiv({ cls: "af-section-card" }); - const detailsHeader = details.createDiv({ cls: "af-section-header" }); - const detailsTitle = detailsHeader.createDiv({ cls: "af-section-title" }); - createIcon(detailsTitle, "file-text"); - detailsTitle.appendText(" Details"); + /** View helpers handed to the channels page (src/views/pages/channelsPage.ts). */ + private channelsPageDeps(): ChannelsPageDeps { + return { + ...this.basePageDeps(), + navigate: (page, context) => this.navigate(page, context), + renderAgentStat: (container, value, label) => this.renderAgentStat(container, value, label), + }; + } - const detailsBody = details.createDiv({ cls: "af-config-form" }); - this.renderConfigRow(detailsBody, "Agent", task.agent); - this.renderConfigRow(detailsBody, "Priority", task.priority.charAt(0).toUpperCase() + task.priority.slice(1)); - this.renderConfigRow(detailsBody, "Status", task.enabled ? "Enabled" : "Disabled"); + /** View helpers handed to the MCP servers page (src/views/pages/mcpPage.ts). */ + private mcpPageDeps(): McpPageDeps { + return { + ...this.basePageDeps(), + navigate: (page) => this.navigate(page), + renderDetailRow: (container, label, value) => this.renderDetailRow(container, label, value), + rerender: () => this.render(), + contentEl: this.contentEl, + mcpProbeCache: this.mcpProbeCache, + authenticatingServers: this.authenticatingServers, + }; + } - // Schedule — human-readable, no raw cron - const scheduleText = task.schedule - ? this.humanizeCron(task.schedule) - : task.runAt ?? "Manual (run on demand)"; - this.renderConfigRow(detailsBody, "Schedule", scheduleText); - if (task.schedule) { - this.renderConfigRow(detailsBody, "Catch up if missed", task.catchUp ? "Yes" : "No"); - } + /** View helpers handed to the agent detail page (src/views/pages/agentDetailPage.ts). */ + private agentDetailPageDeps(): AgentDetailPageDeps { + return { + ...this.basePageDeps(), + navigate: (page, context) => this.navigate(page, context), + healthToClass: (status) => this.healthToClass(status), + renderAgentAvatar: (el, agent) => this.renderAgentAvatar(el, agent), + renderStatCard: (container, label, value, valueSuffix, iconName, sub) => + this.renderStatCard(container, label, value, valueSuffix, iconName, sub), + combinedTotals: (runs, usage) => this.combinedTotals(runs, usage), + renderTimelineItem: (container, run) => this.renderTimelineItem(container, run), + renderConfigRow: (container, label, value) => this.renderConfigRow(container, label, value), + statusToTimelineClass: (status) => this.statusToTimelineClass(status), + statusToIconName: (status) => this.statusToIconName(status), + statusToBadgeClass: (status) => this.statusToBadgeClass(status), + statusToBadgeText: (status) => this.statusToBadgeText(status), + formatStarted: (iso) => this.formatStarted(iso), + formatDuration: (seconds) => this.formatDuration(seconds), + formatTokenCount: (tokens) => formatTokenCount(tokens), + cronToHuman: (cron) => cronToHuman(cron), + timeUntil: (date) => this.timeUntil(date), + openSlideover: (run) => this.openSlideover(run), + openChatSlideover: (agent) => this.openChatSlideover(agent), + getDetailTab: () => this.agentDetailTab, + setDetailTab: (tab) => { + this.agentDetailTab = tab; + }, + rerender: () => this.render(), + }; + } - this.renderConfigRow(detailsBody, "Created", task.created); - this.renderConfigRow(detailsBody, "Runs", String(task.runCount)); - if (task.lastRun) { - this.renderConfigRow(detailsBody, "Last Run", this.formatStarted(task.lastRun)); - } - - // Instructions section - const promptSection = page.createDiv({ cls: "af-section-card" }); - const promptHeader = promptSection.createDiv({ cls: "af-section-header" }); - const promptTitle = promptHeader.createDiv({ cls: "af-section-title" }); - createIcon(promptTitle, "message-square"); - promptTitle.appendText(" Instructions"); - promptSection.createDiv({ cls: "af-output-block", text: task.body || "(empty)" }); - - // Recent runs - const runsSection = page.createDiv({ cls: "af-section-card" }); - const runsHeader = runsSection.createDiv({ cls: "af-section-header" }); - const runsTitle = runsHeader.createDiv({ cls: "af-section-title" }); - createIcon(runsTitle, "scroll-text"); - runsTitle.appendText(" Recent Runs"); - - const runsBody = runsSection.createDiv({ cls: "af-timeline" }); - if (runs.length === 0) { - this.renderEmptyState(runsBody, "scroll-text", "No runs yet", ""); - } else { - for (const run of runs.slice(0, 10)) { - this.renderTimelineItem(runsBody, run); - } - } + /** View helpers handed to the task detail page (src/views/pages/taskDetailPage.ts). */ + private taskDetailPageDeps(): TaskDetailPageDeps { + return { + ...this.basePageDeps(), + navigate: (page, context) => this.navigate(page, context), + renderConfigRow: (container, label, value) => this.renderConfigRow(container, label, value), + renderTimelineItem: (container, run) => this.renderTimelineItem(container, run), + humanizeCron: (cron) => this.humanizeCron(cron), + formatStarted: (iso) => this.formatStarted(iso), + }; } // ═══════════════════════════════════════════════════════ @@ -2900,9 +1550,16 @@ export class FleetDashboardView extends ItemView { } } - if (results.length === 0) return; - const dropdown = searchWrap.createDiv({ cls: "af-search-results" }); + + if (results.length === 0) { + dropdown.createDiv({ + cls: "af-search-result-empty", + text: `No results for "${query}"`, + }); + return; + } + for (const result of results.slice(0, 10)) { const item = dropdown.createDiv({ cls: "af-search-result-item" }); createIcon(item, result.icon, "af-search-result-icon"); @@ -2912,6 +1569,13 @@ export class FleetDashboardView extends ItemView { result.action(); }; } + + if (results.length > 10) { + dropdown.createDiv({ + cls: "af-search-result-footer", + text: `Showing 10 of ${results.length} results`, + }); + } } // ═══════════════════════════════════════════════════════ @@ -3061,7 +1725,13 @@ export class FleetDashboardView extends ItemView { // Empty State // ═══════════════════════════════════════════════════════ - private renderEmptyState(container: HTMLElement, iconName: string, label: string, sublabel: string): void { + private renderEmptyState( + container: HTMLElement, + iconName: string, + label: string, + sublabel: string, + action?: { label: string; onClick: () => void }, + ): void { const empty = container.createDiv({ cls: "af-empty-state" }); const iconEl = empty.createDiv({ cls: "af-empty-icon" }); setIcon(iconEl, iconName); @@ -3069,12 +1739,38 @@ export class FleetDashboardView extends ItemView { if (sublabel) { empty.createDiv({ cls: "af-empty-sublabel", text: sublabel }); } + if (action) { + const actionBtn = empty.createEl("button", { cls: "af-btn-sm primary af-empty-action" }); + createIcon(actionBtn, "plus", "af-btn-icon"); + actionBtn.appendText(` ${action.label}`); + actionBtn.onclick = () => action.onClick(); + } } // ═══════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════ + /** Disable a form submit button and show a progress label while an async + * save is in flight (prevents double-submits). Returns a function that + * restores the original icon + label — call it on failure paths (success + * navigates away, which rebuilds the view). */ + private markSubmitBusy( + btn: HTMLButtonElement, + busyLabel: string, + iconName: string, + label: string, + ): () => void { + btn.disabled = true; + btn.setText(busyLabel); + return () => { + btn.disabled = false; + btn.setText(""); + createIcon(btn, iconName, "af-btn-icon"); + btn.appendText(` ${label}`); + }; + } + private healthToClass(status: AgentHealth): string { switch (status) { case "running": @@ -3233,276 +1929,6 @@ export class FleetDashboardView extends ItemView { } } - private getSkillIcon(name: string): string { - if (name.includes("git")) return "settings"; - if (name.includes("summarize") || name.includes("log")) return "activity"; - if (name.includes("review") || name.includes("check")) return "check-circle-2"; - if (name.includes("vault") || name.includes("note")) return "file-text"; - return "puzzle"; - } - - private renderInlineSchedule( - container: HTMLElement, - state: { schedule: string; type: string }, - ): void { - // Parse current cron into components - const parsed = this.parseCronComponents(state.schedule); - - // Frequency dropdown - const freqRow = container.createDiv({ cls: "af-form-row" }); - freqRow.createDiv({ cls: "af-form-label", text: "Frequency" }); - const freqSelect = freqRow.createEl("select", { cls: "af-form-select" }); - const freqOptions: Array<[string, string]> = [ - ["every_5m", "Every 5 minutes"], - ["every_15m", "Every 15 minutes"], - ["every_30m", "Every 30 minutes"], - ["every_hour", "Every hour"], - ["every_2h", "Every 2 hours"], - ["daily", "Daily"], - ["weekdays", "Weekdays"], - ["weekly", "Weekly"], - ["monthly", "Monthly"], - ]; - for (const [val, lbl] of freqOptions) { - const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === parsed.freq) opt.selected = true; - } - - // Time row (shown for daily/weekdays/weekly/monthly) - const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" }); - timeRow.createDiv({ cls: "af-form-label", text: "Time" }); - const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" }); - - const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); - for (let h = 0; h < 24; h++) { - const ampm = h >= 12 ? "PM" : "AM"; - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; - const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } }); - if (h === parsed.hour) opt.selected = true; - } - - timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" }); - - const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); - for (let m = 0; m < 60; m += 5) { - const opt = minSelect.createEl("option", { - text: String(m).padStart(2, "0"), - attr: { value: String(m) }, - }); - if (m === parsed.minute) opt.selected = true; - } - - // Day row (shown for weekly) - const dayRow = container.createDiv({ cls: "af-form-row af-schedule-day-row" }); - dayRow.createDiv({ cls: "af-form-label", text: "Day" }); - const dayWrap = dayRow.createDiv({ cls: "af-schedule-day-buttons" }); - const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const selectedDays = new Set(parsed.days); - - for (let d = 0; d < 7; d++) { - const btn = dayWrap.createEl("button", { - cls: `af-schedule-day-btn${selectedDays.has(d) ? " active" : ""}`, - text: dayNames[d]!, - }); - btn.onclick = () => { - if (selectedDays.has(d)) selectedDays.delete(d); - else selectedDays.add(d); - btn.toggleClass("active", selectedDays.has(d)); - buildCron(); - }; - } - - // Day-of-month row (shown for monthly) - const domRow = container.createDiv({ cls: "af-form-row af-schedule-dom-row" }); - domRow.createDiv({ cls: "af-form-label", text: "Day of month" }); - const domSelect = domRow.createEl("select", { cls: "af-form-select af-form-select-sm" }); - for (let d = 1; d <= 28; d++) { - const opt = domSelect.createEl("option", { text: String(d), attr: { value: String(d) } }); - if (d === parsed.dayOfMonth) opt.selected = true; - } - - // Visibility logic - const showHideFields = () => { - const freq = freqSelect.value; - const needsTime = ["daily", "weekdays", "weekly", "monthly"].includes(freq); - const needsDay = freq === "weekly"; - const needsDom = freq === "monthly"; - timeRow.setCssStyles({ display: needsTime ? "" : "none" }); - dayRow.setCssStyles({ display: needsDay ? "" : "none" }); - domRow.setCssStyles({ display: needsDom ? "" : "none" }); - }; - - // Build cron from selections - const buildCron = () => { - const freq = freqSelect.value; - const h = hourSelect.value; - const m = minSelect.value; - let cron = ""; - switch (freq) { - case "every_5m": cron = "*/5 * * * *"; break; - case "every_15m": cron = "*/15 * * * *"; break; - case "every_30m": cron = "*/30 * * * *"; break; - case "every_hour": cron = "0 * * * *"; break; - case "every_2h": cron = "0 */2 * * *"; break; - case "daily": cron = `${m} ${h} * * *`; break; - case "weekdays": cron = `${m} ${h} * * 1-5`; break; - case "weekly": { - const days = Array.from(selectedDays).sort().join(",") || "1"; - cron = `${m} ${h} * * ${days}`; - break; - } - case "monthly": cron = `${m} ${h} ${domSelect.value} * *`; break; - } - state.schedule = cron; - state.type = "recurring"; - }; - - freqSelect.addEventListener("change", () => { showHideFields(); buildCron(); }); - hourSelect.addEventListener("change", buildCron); - minSelect.addEventListener("change", buildCron); - domSelect.addEventListener("change", buildCron); - - showHideFields(); - } - - /** - * Simplified schedule picker for heartbeat — frequency intervals + time-of-day, - * no weekly/monthly/day-of-week selection. Outputs a cron expression into - * `state.heartbeatSchedule`. - */ - private renderHeartbeatSchedule( - container: HTMLElement, - state: { heartbeatSchedule: string }, - ): void { - const parsed = this.parseCronComponents(state.heartbeatSchedule); - - // Frequency dropdown — intervals only, no weekly/monthly - const freqRow = container.createDiv({ cls: "af-form-row" }); - freqRow.createDiv({ cls: "af-form-label", text: "Frequency" }); - const freqSelect = freqRow.createEl("select", { cls: "af-form-select" }); - const freqOptions: Array<[string, string]> = [ - ["every_5m", "Every 5 minutes"], - ["every_15m", "Every 15 minutes"], - ["every_30m", "Every 30 minutes"], - ["every_hour", "Every hour"], - ["every_2h", "Every 2 hours"], - ["every_4h", "Every 4 hours"], - ["every_6h", "Every 6 hours"], - ["every_12h", "Every 12 hours"], - ["daily", "Once a day"], - ]; - // Map the current schedule to a freq key - let currentFreq = "every_hour"; - const cronToFreq: Record = { - "*/5 * * * *": "every_5m", - "*/15 * * * *": "every_15m", - "*/30 * * * *": "every_30m", - "0 * * * *": "every_hour", - "0 */2 * * *": "every_2h", - "0 */4 * * *": "every_4h", - "0 */6 * * *": "every_6h", - "0 */12 * * *": "every_12h", - }; - if (cronToFreq[state.heartbeatSchedule]) { - currentFreq = cronToFreq[state.heartbeatSchedule]!; - } else if (parsed.freq === "daily" || parsed.freq === "weekdays") { - currentFreq = "daily"; - } - - for (const [val, lbl] of freqOptions) { - const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === currentFreq) opt.selected = true; - } - - // Time row — only shown for "once a day" - const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" }); - timeRow.createDiv({ cls: "af-form-label", text: "Time" }); - const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" }); - - const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); - for (let h = 0; h < 24; h++) { - const ampm = h >= 12 ? "PM" : "AM"; - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; - const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } }); - if (h === parsed.hour) opt.selected = true; - } - - timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" }); - - const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); - for (let m = 0; m < 60; m += 5) { - const opt = minSelect.createEl("option", { - text: String(m).padStart(2, "0"), - attr: { value: String(m) }, - }); - if (m === parsed.minute) opt.selected = true; - } - - const showHide = () => { - timeRow.setCssStyles({ display: freqSelect.value === "daily" ? "" : "none" }); - }; - - const buildCron = () => { - const freq = freqSelect.value; - const h = hourSelect.value; - const m = minSelect.value; - switch (freq) { - case "every_5m": state.heartbeatSchedule = "*/5 * * * *"; break; - case "every_15m": state.heartbeatSchedule = "*/15 * * * *"; break; - case "every_30m": state.heartbeatSchedule = "*/30 * * * *"; break; - case "every_hour": state.heartbeatSchedule = "0 * * * *"; break; - case "every_2h": state.heartbeatSchedule = "0 */2 * * *"; break; - case "every_4h": state.heartbeatSchedule = "0 */4 * * *"; break; - case "every_6h": state.heartbeatSchedule = "0 */6 * * *"; break; - case "every_12h": state.heartbeatSchedule = "0 */12 * * *"; break; - case "daily": state.heartbeatSchedule = `${m} ${h} * * *`; break; - } - }; - - freqSelect.addEventListener("change", () => { showHide(); buildCron(); }); - hourSelect.addEventListener("change", buildCron); - minSelect.addEventListener("change", buildCron); - - showHide(); - } - - private parseCronComponents(cron: string): { - freq: string; - hour: number; - minute: number; - days: number[]; - dayOfMonth: number; - } { - const defaults = { freq: "daily", hour: 9, minute: 0, days: [1], dayOfMonth: 1 }; - if (!cron?.trim()) return defaults; - - const shortcutMap: Record = { - "*/5 * * * *": "every_5m", - "*/15 * * * *": "every_15m", - "*/30 * * * *": "every_30m", - "0 * * * *": "every_hour", - "0 */2 * * *": "every_2h", - }; - if (shortcutMap[cron]) return { ...defaults, freq: shortcutMap[cron] }; - - const parts = cron.trim().split(/\s+/); - if (parts.length !== 5) return defaults; - const [min, hr, dom, , dow] = parts; - const h = parseInt(hr ?? "9", 10); - const m = parseInt(min ?? "0", 10); - - if (dom === "*" && dow === "*") return { ...defaults, freq: "daily", hour: h, minute: m }; - if (dom === "*" && dow === "1-5") return { ...defaults, freq: "weekdays", hour: h, minute: m }; - if (dom === "*" && dow !== "*") { - const days = (dow ?? "1").split(",").map((d) => parseInt(d, 10)); - return { ...defaults, freq: "weekly", hour: h, minute: m, days }; - } - if (dow === "*" && dom !== "*") { - return { ...defaults, freq: "monthly", hour: h, minute: m, dayOfMonth: parseInt(dom ?? "1", 10) }; - } - - return { ...defaults, hour: h, minute: m }; - } private humanizeCron(cron: string): string { // Check shorthand aliases first @@ -3578,505 +2004,7 @@ export class FleetDashboardView extends ItemView { private renderCreateAgentPage(container: HTMLElement): void { const page = container.createDiv({ cls: "af-create-agent-page" }); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatar, "plus"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Agent" }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new agent for your fleet" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - // Form state - const state = { - name: "", - description: "", - avatar: "", - tags: "", - systemPrompt: "", - model: "default", - adapter: "claude-code", - cwd: "", - timeout: 300, - permissionMode: "bypassPermissions", - effort: "", - selectedSkills: new Set(), - selectedMcpServers: new Set(), - skillsBody: "", - contextBody: "", - approvalRequired: "", - memory: true, - enabled: true, - allowedCommands: "", - blockedCommands: "", - heartbeatEnabled: false, - heartbeatSchedule: "0 */6 * * *", - heartbeatBody: "", - heartbeatNotify: true, - heartbeatChannel: "", - heartbeatChannelTarget: "", - autoCompactThreshold: 85, - wikiReferences: [] as string[], - }; - - const TEMPLATES: Record = { - none: { label: "None", prompt: "" }, - coding: { label: "Coding Agent", prompt: "You are a coding agent. Review code, write tests, fix bugs, and implement features.\nFollow existing code conventions. Write clean, well-tested code.\nIf something is unclear, ask for clarification instead of guessing." }, - monitor: { label: "Monitor", prompt: "You are a monitoring agent. Check system status, alert on failures, and report on health metrics.\nBe concise and factual. Highlight anomalies clearly.\nInclude timestamps and relevant context in all reports." }, - briefing: { label: "Briefing", prompt: "You are a briefing agent. Summarize activity, generate reports, and surface key changes.\nPrioritize recent and important changes. Keep summaries concise.\nEnd with explicit next actions if they exist." }, - reviewer: { label: "Code Reviewer", prompt: "You are a code review agent. Analyze pull requests, suggest improvements, and flag potential issues.\nFocus on correctness, security, and maintainability.\nBe specific — reference file names and line numbers." }, - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Identity Section ─── - const identitySection = form.createDiv({ cls: "af-create-section" }); - const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); - const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(identityIcon, "user"); - identityHeader.createSpan({ text: "Identity" }); - - this.createFormField(identitySection, "Name", "deploy-watcher", "Unique identifier (will be slugified)", (v) => { state.name = v; }); - this.createFormField(identitySection, "Description", "Monitors deployments and reports status", "", (v) => { state.description = v; }); - - const avatarRow = identitySection.createDiv({ cls: "af-form-row" }); - avatarRow.createDiv({ cls: "af-form-label", text: "Avatar" }); - const avatarInput = avatarRow.createEl("input", { - cls: "af-form-input af-form-input-sm", - attr: { type: "text", placeholder: "🛡️" }, - }); - avatarInput.addEventListener("input", () => { state.avatar = avatarInput.value; }); - - this.createFormField(identitySection, "Tags", "devops, monitoring", "Comma-separated", (v) => { state.tags = v; }); - - // Enabled toggle - const enabledRow = identitySection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" }); - enabledToggle.onclick = () => { - const isOn = enabledToggle.hasClass("on"); - enabledToggle.toggleClass("on", !isOn); - state.enabled = !isOn; - }; - - // ─── System Prompt Section ─── - const promptSection = form.createDiv({ cls: "af-create-section" }); - const promptHeader = promptSection.createDiv({ cls: "af-create-section-header" }); - const promptIcon = promptHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(promptIcon, "message-square"); - promptHeader.createSpan({ text: "System Prompt" }); - - const templateRow = promptSection.createDiv({ cls: "af-form-row" }); - templateRow.createDiv({ cls: "af-form-label", text: "Template" }); - const templateSelect = templateRow.createEl("select", { cls: "af-form-select" }); - for (const [key, { label }] of Object.entries(TEMPLATES)) { - templateSelect.createEl("option", { text: label, attr: { value: key } }); - } - - const promptTextarea = promptSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "You are a deployment monitoring agent...", rows: "10" }, - }); - promptTextarea.addEventListener("input", () => { state.systemPrompt = promptTextarea.value; }); - - templateSelect.addEventListener("change", () => { - const preset = TEMPLATES[templateSelect.value]; - if (preset && templateSelect.value !== "none") { - state.systemPrompt = preset.prompt; - promptTextarea.value = preset.prompt; - } - }); - - // ─── Runtime Config Section ─── - const configSection = form.createDiv({ cls: "af-create-section" }); - const configHeader = configSection.createDiv({ cls: "af-create-section-header" }); - const configIcon = configHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(configIcon, "settings"); - configHeader.createSpan({ text: "Runtime Config" }); - - const configGrid = configSection.createDiv({ cls: "af-create-config-grid" }); - - // Adapter (before model, so model dropdown updates when adapter changes) - const adapterRow = configGrid.createDiv({ cls: "af-form-row" }); - adapterRow.createDiv({ cls: "af-form-label", text: "Adapter" }); - const adapterSelect = adapterRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl, disabled] of ADAPTER_FORM_OPTIONS) { - const opt = adapterSelect.createEl("option", { text: lbl, attr: { value: val, ...(disabled ? { disabled: "true" } : {}) } }); - if (val === "claude-code") opt.selected = true; - } - - // Model (dynamic based on adapter) - const modelRow = configGrid.createDiv({ cls: "af-form-row" }); - const modelLabel = modelRow.createDiv({ cls: "af-form-label", text: "Model" }); - this.addTooltip( - modelLabel, - `Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${this.plugin.settings.defaultModel || "CLI default"}).`, - ); - const modelFieldWrap = modelRow.createDiv({ cls: "af-form-field-wrap" }); - const renderModelField = () => { - renderModelPicker(modelFieldWrap, { - value: state.model, - adapter: state.adapter, - onChange: (value) => { state.model = value; }, - }); - }; - renderModelField(); - - // The permission dropdown is built further down; it registers its - // repopulate hook here so adapter switches refresh it too. - let repopulatePermModeSelect: () => void = () => { /* assigned below */ }; - - adapterSelect.addEventListener("change", () => { - state.adapter = adapterSelect.value; - // A model alias from the other vendor would be passed verbatim and - // rejected by the CLI \u2014 reset to "use default" on family switch. - const otherAliases = isCodexAdapterValue(state.adapter) ? MODEL_ALIASES : CODEX_MODEL_ALIASES; - if (otherAliases.some((a) => a.value === state.model.trim())) { - state.model = ""; - } - renderModelField(); - state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); - repopulatePermModeSelect(); - }); - - // Working Dir - const cwdRow = configGrid.createDiv({ cls: "af-form-row" }); - cwdRow.createDiv({ cls: "af-form-label", text: "Working Dir" }); - const cwdInput = cwdRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", placeholder: "Leave empty for vault root" }, - }); - cwdInput.addEventListener("input", () => { state.cwd = cwdInput.value; }); - - // Timeout - const timeoutRow = configGrid.createDiv({ cls: "af-form-row" }); - timeoutRow.createDiv({ cls: "af-form-label", text: "Timeout (sec)" }); - const timeoutInput = timeoutRow.createEl("input", { - cls: "af-form-input af-form-input-sm", - attr: { type: "number", value: "300" }, - }); - timeoutInput.addEventListener("input", () => { - const n = parseInt(timeoutInput.value, 10); - if (!isNaN(n) && n > 0) state.timeout = n; - }); - - // Permission Mode (options depend on the selected adapter) - const permRow = configGrid.createDiv({ cls: "af-form-row" }); - permRow.createDiv({ cls: "af-form-label", text: "Permission Mode" }); - const permSelect = permRow.createEl("select", { cls: "af-form-select" }); - const permDescEl = configGrid.createDiv({ cls: "af-form-hint", text: "" }); - repopulatePermModeSelect = () => { - // Snap any value left over from the other adapter family to its - // nearest equivalent so the dropdown always reflects what gets saved. - state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); - const options = permModeOptionsFor(state.adapter); - permSelect.empty(); - for (const [val, lbl] of options) { - const opt = permSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === state.permissionMode) opt.selected = true; - } - permDescEl.textContent = options.find(([v]) => v === permSelect.value)?.[2] ?? ""; - }; - repopulatePermModeSelect(); - permSelect.addEventListener("change", () => { - state.permissionMode = permSelect.value; - permDescEl.textContent = permModeOptionsFor(state.adapter).find(([v]) => v === permSelect.value)?.[2] ?? ""; - }); - - // Effort Level - const effortRow = configGrid.createDiv({ cls: "af-form-row" }); - effortRow.createDiv({ cls: "af-form-label", text: "Effort Level" }); - const effortSelect = effortRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl] of [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) { - effortSelect.createEl("option", { text: lbl, attr: { value: val } }); - } - effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; }); - configGrid.createDiv({ cls: "af-form-hint", text: "Controls reasoning depth — low is fast, max is most thorough" }); - - // Auto-compact threshold - const compactRow = configGrid.createDiv({ cls: "af-form-row" }); - const compactLabel = compactRow.createDiv({ cls: "af-form-label", text: "Auto-compact at" }); - this.addTooltip( - compactLabel, - "Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.", - ); - const compactInput = compactRow.createEl("input", { - cls: "af-form-input af-form-input-sm", - attr: { type: "number", min: "0", max: "100", value: String(state.autoCompactThreshold) }, - }); - compactInput.addEventListener("input", () => { - const n = parseInt(compactInput.value, 10); - if (!isNaN(n) && n >= 0 && n <= 100) state.autoCompactThreshold = n; - }); - configGrid.createDiv({ cls: "af-form-hint", text: "0 disables auto-compact" }); - - // Wiki references (consumer-mode read access to other keepers' scopes) - { - const wikiKeepers = this.plugin.runtime.getSnapshot().agents.filter((a) => a.wikiKeeper !== undefined); - if (wikiKeepers.length > 0) { - const wrRow = configGrid.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const wrLabel = wrRow.createDiv({ cls: "af-form-label", text: "Wiki access" }); - this.addTooltip( - wrLabel, - "Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).", - ); - const wrWrap = wrRow.createDiv({ cls: "af-form-field-wrap" }); - for (const wk of wikiKeepers) { - const label = wrWrap.createEl("label", { cls: "af-form-checkbox-row" }); - const cb = label.createEl("input", { attr: { type: "checkbox" } }); - label.createSpan({ text: ` ${wk.name}`, cls: "af-form-checkbox-label" }); - cb.addEventListener("change", () => { - if (cb.checked) { - if (!state.wikiReferences.includes(wk.name)) state.wikiReferences.push(wk.name); - } else { - state.wikiReferences = state.wikiReferences.filter((n) => n !== wk.name); - } - }); - } - } - } - - // ─── Heartbeat Section ─── - { - const heartbeatSection = form.createDiv({ cls: "af-create-section" }); - const heartbeatHeader = heartbeatSection.createDiv({ cls: "af-create-section-header" }); - const heartbeatIcon = heartbeatHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(heartbeatIcon, "heart-pulse"); - const heartbeatHeaderLabel = heartbeatHeader.createSpan({ text: "Heartbeat" }); - this.addTooltip(heartbeatHeaderLabel, "Autonomous periodic run — what the agent does when no one is asking"); - - const hbEnabledRow = heartbeatSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - hbEnabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const hbEnabledToggle = hbEnabledRow.createDiv({ cls: "af-agent-card-toggle" }); - const hbBody = heartbeatSection.createDiv(); - hbBody.setCssStyles({ display: "none" }); - hbEnabledToggle.onclick = () => { - const isOn = hbEnabledToggle.hasClass("on"); - hbEnabledToggle.toggleClass("on", !isOn); - state.heartbeatEnabled = !isOn; - hbBody.setCssStyles({ display: !isOn ? "" : "none" }); - }; - - this.renderHeartbeatSchedule(hbBody, state); - - const hbNotifyRow = hbBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const hbNotifyLabel = hbNotifyRow.createDiv({ cls: "af-form-label" }); - hbNotifyLabel.setText("Notify"); - this.addTooltip(hbNotifyLabel, "Show an Obsidian notice when the heartbeat completes"); - const hbNotifyToggle = hbNotifyRow.createDiv({ cls: "af-agent-card-toggle on" }); - hbNotifyToggle.onclick = () => { - const isOn = hbNotifyToggle.hasClass("on"); - hbNotifyToggle.toggleClass("on", !isOn); - state.heartbeatNotify = !isOn; - }; - - const createSnapshot = this.plugin.runtime.getSnapshot(); - const hbChannelRow = hbBody.createDiv({ cls: "af-form-row" }); - const hbChannelLabel = hbChannelRow.createDiv({ cls: "af-form-label" }); - hbChannelLabel.setText("Post to channel"); - this.addTooltip(hbChannelLabel, "Heartbeat results are posted to this Slack channel when the run completes"); - const hbChannelSelect = hbChannelRow.createEl("select", { cls: "af-form-select" }); - hbChannelSelect.createEl("option", { text: "(none)", attr: { value: "" } }); - for (const ch of createSnapshot.channels) { - hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } }); - } - hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); }); - - const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" }); - const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" }); - hbTargetLabel.setText("Target ID"); - this.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel’s first allowed user."); - const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } }); - hbTargetInput.value = state.heartbeatChannelTarget; - hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); }); - const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); }; - syncHbTarget(); - - const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" }); - hbInstructionLabel.setCssStyles({ width: "auto" }); - hbInstructionLabel.setCssStyles({ marginTop: "12px" }); - hbInstructionLabel.setText("Instruction"); - this.addTooltip(hbInstructionLabel, "What the agent does on each heartbeat. Also used by the \"Run Now\" button."); - const hbTextarea = hbBody.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Check status, scan for issues, report findings...", rows: "8" }, - }); - hbTextarea.addEventListener("input", () => { state.heartbeatBody = hbTextarea.value; }); - } - - // ─── Skills Section ─── - const skillsSection = form.createDiv({ cls: "af-create-section" }); - const skillsHeader = skillsSection.createDiv({ cls: "af-create-section-header" }); - const skillsIcon = skillsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(skillsIcon, "puzzle"); - skillsHeader.createSpan({ text: "Skills" }); - - const snapshot = this.plugin.runtime.getSnapshot(); - if (snapshot.skills.length > 0) { - skillsSection.createDiv({ cls: "af-form-sublabel", text: "Shared Skills" }); - const skillsGrid = skillsSection.createDiv({ cls: "af-create-skills-grid" }); - for (const skill of snapshot.skills) { - const item = skillsGrid.createDiv({ cls: "af-create-skill-item" }); - const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } }); - cb.addEventListener("change", () => { - if (cb.checked) state.selectedSkills.add(skill.name); - else state.selectedSkills.delete(skill.name); - }); - const lbl = item.createDiv({ cls: "af-create-skill-label" }); - lbl.createSpan({ cls: "af-create-skill-name", text: skill.name }); - if (skill.description) { - lbl.createSpan({ cls: "af-create-skill-desc", text: ` — ${skill.description}` }); - } - } - } - - const agentSkillsLabel = skillsSection.createDiv({ cls: "af-form-sublabel" }); - agentSkillsLabel.setText("Agent-specific skills"); - this.addTooltip(agentSkillsLabel, "Custom skills/instructions only for this agent, not shared with others"); - const skillsTextarea = skillsSection.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Custom skills/instructions for this agent...", rows: "4" }, - }); - skillsTextarea.addEventListener("input", () => { state.skillsBody = skillsTextarea.value; }); - - // ─── MCP Servers Section ─── - { - const mcpSection = form.createDiv({ cls: "af-create-section" }); - const mcpHeader = mcpSection.createDiv({ cls: "af-create-section-header" }); - const mcpIcon = mcpHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(mcpIcon, "plug"); - const mcpHeaderLabel = mcpHeader.createSpan({ text: "MCP Servers" }); - this.addTooltip(mcpHeaderLabel, "Grant agent access to MCP servers"); - this.renderAgentMcpPicker(mcpSection, state.selectedMcpServers); - } - - // ─── Context Section ─── - const contextSection = form.createDiv({ cls: "af-create-section" }); - const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); - const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(contextIcon, "file-text"); - const contextHeaderLabel = contextHeader.createSpan({ text: "Context" }); - this.addTooltip(contextHeaderLabel, "Project-specific context included in every run"); - const contextTextarea = contextSection.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Background info, repo structure, conventions...", rows: "4" }, - }); - contextTextarea.addEventListener("input", () => { state.contextBody = contextTextarea.value; }); - - // ─── Permissions Section ─── - const permsSection = form.createDiv({ cls: "af-create-section" }); - const permsHeader = permsSection.createDiv({ cls: "af-create-section-header" }); - const permsIcon = permsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(permsIcon, "shield-check"); - permsHeader.createSpan({ text: "Permissions" }); - - this.createFormField(permsSection, "Approval required", "git_push, file_delete", "Comma-separated tool names", (v) => { state.approvalRequired = v; }); - - const allowRow = permsSection.createDiv({ cls: "af-form-row" }); - allowRow.createDiv({ cls: "af-form-label", text: "Allowed Commands" }); - const allowTextarea = allowRow.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Bash(curl *)\nBash(python3 *)\nRead\nEdit\nWrite", rows: "4" }, - }); - allowTextarea.addEventListener("input", () => { state.allowedCommands = allowTextarea.value; }); - - const denyRow = permsSection.createDiv({ cls: "af-form-row" }); - denyRow.createDiv({ cls: "af-form-label", text: "Blocked Commands" }); - const denyTextarea = denyRow.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Bash(git push *)\nBash(rm -rf *)\nBash(sudo *)", rows: "4" }, - }); - denyTextarea.addEventListener("input", () => { state.blockedCommands = denyTextarea.value; }); - - permsSection.createDiv({ - cls: "af-form-hint", - text: - "On Codex agents these become execpolicy command rules — only Bash(cmd args *) " + - "prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are " + - "ignored, and file/network access is governed by Permission Mode (the sandbox).", - }); - - const memoryRow = permsSection.createDiv({ cls: "af-form-row" }); - memoryRow.createDiv({ cls: "af-form-label", text: "Memory enabled" }); - const memoryToggle = memoryRow.createDiv({ cls: "af-agent-card-toggle on" }); - memoryToggle.onclick = () => { - const isOn = memoryToggle.hasClass("on"); - memoryToggle.toggleClass("on", !isOn); - state.memory = !isOn; - }; - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("agents"); - - const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(createBtn, "plus", "af-btn-icon"); - createBtn.appendText(" Create Agent"); - createBtn.onclick = async () => { - const name = state.name.trim(); - if (!name) { - new Notice("Agent name is required."); - return; - } - const slug = slugify(name); - if (this.plugin.repository.getAgentByName(slug)) { - new Notice(`Agent "${slug}" already exists.`); - return; - } - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - try { - const parseLines = (s: string) => splitLines(s).map((l) => l.trim()).filter(Boolean); - await this.plugin.repository.createAgentFolder({ - name: slug, - description: state.description.trim(), - avatar: state.avatar.trim(), - tags: parseTags(state.tags), - systemPrompt: state.systemPrompt.trim(), - model: state.model.trim() || "default", - adapter: state.adapter, - cwd: state.cwd.trim(), - timeout: state.timeout, - permissionMode: state.permissionMode, - effort: state.effort || undefined, - approvalRequired: parseTags(state.approvalRequired), - memory: state.memory, - memoryMaxEntries: 100, - skills: Array.from(state.selectedSkills), - mcpServers: Array.from(state.selectedMcpServers), - skillsBody: state.skillsBody.trim(), - contextBody: state.contextBody.trim(), - enabled: state.enabled, - permissionRules: { - allow: parseLines(state.allowedCommands), - deny: parseLines(state.blockedCommands), - }, - autoCompactThreshold: state.autoCompactThreshold, - wikiReferences: state.wikiReferences, - }); - - // Save heartbeat if configured - if (state.heartbeatEnabled && state.heartbeatBody.trim()) { - await this.plugin.repository.updateHeartbeat(slug, { - enabled: state.heartbeatEnabled, - schedule: state.heartbeatSchedule.trim(), - notify: state.heartbeatNotify, - channel: state.heartbeatChannel, - channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "", - body: state.heartbeatBody.trim(), - }); - } - - new Notice(`Agent "${slug}" created.`); - await this.plugin.refreshFromVault(); - this.navigate("agent-detail", slug); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to create agent: ${msg}`); - } - }; + renderCreateAgentForm(page, this.agentFormDeps()); } // ═══════════════════════════════════════════════════════ @@ -4085,154 +2013,7 @@ export class FleetDashboardView extends ItemView { private renderCreateSkillPage(container: HTMLElement): void { const page = container.createDiv({ cls: "af-create-agent-page" }); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatar, "plus"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Skill" }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Define a reusable skill for your agents" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - // Form state - const state = { - name: "", - description: "", - tags: "", - body: "", - toolsBody: "", - referencesBody: "", - examplesBody: "", - }; - - const SKILL_TEMPLATES: Record = { - none: { label: "None", prompt: "" }, - cli: { label: "CLI Tool Wrapper", prompt: "You are using the {{tool}} CLI. All operations go through the wrapper script.\n\nRequirements:\n- Ensure required environment variables are set\n- Parse JSON responses for human-readable output\n- Confirm destructive operations before executing\n\nKey behaviors:\n- List existing items before making changes\n- Use --dry-run flags when available\n- Report errors clearly with suggested fixes" }, - api: { label: "API Integration", prompt: "You are integrating with the {{service}} API.\n\nBase URL: https://api.example.com/v1\nAuth: Bearer token via environment variable\n\nKey behaviors:\n- Always check rate limits before bulk operations\n- Handle pagination for list endpoints\n- Validate inputs before making requests\n- Parse and format JSON responses for readability" }, - review: { label: "Code Review", prompt: "You are a code review skill. Analyze code changes and provide structured feedback.\n\nReview checklist:\n- Correctness: Does the code do what it claims?\n- Security: Any injection, auth, or data exposure risks?\n- Performance: Unnecessary allocations, N+1 queries, missing indexes?\n- Maintainability: Clear naming, reasonable complexity, adequate tests?\n\nOutput format:\n- Start with a 1-line summary\n- Group findings by severity (critical, warning, suggestion)\n- Reference specific file paths and line numbers" }, - data: { label: "Data Analysis", prompt: "You are a data analysis skill. Query, transform, and report on data.\n\nKey behaviors:\n- Summarize datasets before diving into details\n- Use tables and charts where appropriate\n- Always state the time range and filters applied\n- Flag anomalies and outliers explicitly\n- End with actionable insights, not just observations" }, - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Identity Section ─── - const identitySection = form.createDiv({ cls: "af-create-section" }); - const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); - const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(identityIcon, "puzzle"); - identityHeader.createSpan({ text: "Identity" }); - - this.createFormField(identitySection, "Name", "todoist", "Unique identifier (will be slugified)", (v) => { state.name = v; }); - this.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; }); - this.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; }); - - // ─── Core Instructions Section ─── - const instructionsSection = form.createDiv({ cls: "af-create-section" }); - const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); - const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(instructionsIcon, "file-text"); - instructionsHeader.createSpan({ text: "Core Instructions" }); - - const templateRow = instructionsSection.createDiv({ cls: "af-form-row" }); - templateRow.createDiv({ cls: "af-form-label", text: "Template" }); - const templateSelect = templateRow.createEl("select", { cls: "af-form-select" }); - for (const [key, { label }] of Object.entries(SKILL_TEMPLATES)) { - templateSelect.createEl("option", { text: label, attr: { value: key } }); - } - - const bodyTextarea = instructionsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Skill instructions — what does this skill do and how should agents use it?", rows: "10" }, - }); - bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; }); - - templateSelect.addEventListener("change", () => { - const preset = SKILL_TEMPLATES[templateSelect.value]; - if (preset && templateSelect.value !== "none") { - state.body = preset.prompt; - bodyTextarea.value = preset.prompt; - } - }); - - // ─── Tools Section ─── - const toolsSection = form.createDiv({ cls: "af-create-section" }); - const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" }); - const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(toolsIcon, "wrench"); - const toolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" }); - this.addTooltip(toolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill"); - const toolsTextarea = toolsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "## Commands\n\n### list\nUsage: tool list [--filter ]\n...", rows: "8" }, - }); - toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; }); - - // ─── References Section ─── - const refsSection = form.createDiv({ cls: "af-create-section" }); - const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" }); - const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(refsIcon, "book-open"); - const refsHeaderLabel = refsHeader.createSpan({ text: "References" }); - this.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets"); - const refsTextarea = refsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" }, - }); - refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; }); - - // ─── Examples Section ─── - const examplesSection = form.createDiv({ cls: "af-create-section" }); - const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" }); - const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(examplesIcon, "message-circle"); - const examplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" }); - this.addTooltip(examplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill"); - const examplesTextarea = examplesSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "## Example: List all tasks\n\nUser: Show me my tasks for today\n\nAgent: ...", rows: "6" }, - }); - examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; }); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("skills"); - - const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(createBtn, "plus", "af-btn-icon"); - createBtn.appendText(" Create Skill"); - createBtn.onclick = async () => { - const name = state.name.trim(); - if (!name) { - new Notice("Skill name is required."); - return; - } - const slug = slugify(name); - if (this.plugin.repository.getSkillByName(slug)) { - new Notice(`Skill "${slug}" already exists.`); - return; - } - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - try { - await this.plugin.repository.createSkillFolder({ - name: slug, - description: state.description.trim(), - tags: parseTags(state.tags), - body: state.body.trim(), - toolsBody: state.toolsBody.trim(), - referencesBody: state.referencesBody.trim(), - examplesBody: state.examplesBody.trim(), - }); - new Notice(`Skill "${slug}" created.`); - await this.plugin.refreshFromVault(); - this.navigate("skills"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to create skill: ${msg}`); - } - }; + renderSkillForm(page, this.skillFormDeps()); } // ═══════════════════════════════════════════════════════ @@ -4253,579 +2034,7 @@ export class FleetDashboardView extends ItemView { return; } - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatarEl = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatarEl, "edit"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Agent: ${agent.name}` }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify agent configuration" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - // Form state pre-filled - const state = { - name: agent.name, - description: agent.description ?? "", - avatar: agent.avatar, - tags: agent.tags.join(", "), - systemPrompt: agent.body, - model: agent.model, - adapter: agent.adapter, - cwd: agent.cwd ?? "", - timeout: agent.timeout, - permissionMode: agent.permissionMode, - effort: agent.effort ?? "", - selectedSkills: new Set(agent.skills), - selectedMcpServers: new Set(agent.mcpServers ?? []), - skillsBody: agent.skillsBody, - contextBody: agent.contextBody, - approvalRequired: agent.approvalRequired.join(", "), - memory: agent.memory, - memoryTokenBudget: agent.memoryTokenBudget, - reflectionEnabled: agent.reflection.enabled, - reflectionProposeSkills: agent.reflection.proposeSkills, - enabled: agent.enabled, - allowedCommands: agent.permissionRules.allow.join("\n"), - blockedCommands: agent.permissionRules.deny.join("\n"), - heartbeatEnabled: agent.heartbeatEnabled, - heartbeatSchedule: agent.heartbeatSchedule, - heartbeatBody: agent.heartbeatBody, - heartbeatNotify: agent.heartbeatNotify, - heartbeatChannel: agent.heartbeatChannel, - heartbeatChannelTarget: agent.heartbeatChannelTarget, - autoCompactThreshold: agent.autoCompactThreshold ?? 85, - wikiReferences: (agent.wikiReferences ?? []).map((r) => r.agent), - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Identity Section ─── - const identitySection = form.createDiv({ cls: "af-create-section" }); - const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); - const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(identityIcon, "user"); - identityHeader.createSpan({ text: "Identity" }); - - // Name (read-only for edit) - const nameRow = identitySection.createDiv({ cls: "af-form-row" }); - nameRow.createDiv({ cls: "af-form-label", text: "Name" }); - const nameInput = nameRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", value: agent.name, disabled: "true" }, - }); - nameInput.setCssStyles({ opacity: "0.6" }); - - this.createFormField(identitySection, "Description", "Monitors deployments and reports status", "", (v) => { state.description = v; }, agent.description ?? ""); - - const avatarRow = identitySection.createDiv({ cls: "af-form-row" }); - avatarRow.createDiv({ cls: "af-form-label", text: "Avatar" }); - const avatarPickerBtn = avatarRow.createEl("button", { cls: "af-avatar-picker-btn" }); - const avatarPreview = avatarPickerBtn.createDiv({ cls: "af-avatar-picker-preview" }); - this.renderAgentAvatar(avatarPreview, { ...agent, avatar: state.avatar ?? agent.avatar }); - avatarPickerBtn.createSpan({ cls: "af-avatar-picker-label", text: state.avatar || agent.avatar || "Pick icon…" }); - avatarPickerBtn.addEventListener("click", () => { - new IconPickerModal(this.app, state.avatar ?? agent.avatar, (iconName) => { - state.avatar = iconName; - avatarPreview.empty(); - setIcon(avatarPreview, iconName); - avatarPickerBtn.querySelector(".af-avatar-picker-label")?.setText(iconName); - }).open(); - }); - - this.createFormField(identitySection, "Tags", "devops, monitoring", "Comma-separated", (v) => { state.tags = v; }, agent.tags.join(", ")); - - // ─── Enabled Toggle ─── - const enabledRow = identitySection.createDiv({ cls: "af-form-row" }); - enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` }); - enabledToggle.onclick = () => { - const isOn = enabledToggle.hasClass("on"); - enabledToggle.toggleClass("on", !isOn); - state.enabled = !isOn; - }; - - // ─── System Prompt Section ─── - const promptSection = form.createDiv({ cls: "af-create-section" }); - const promptHeader = promptSection.createDiv({ cls: "af-create-section-header" }); - const promptIcon = promptHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(promptIcon, "message-square"); - promptHeader.createSpan({ text: "System Prompt" }); - - const promptTextarea = promptSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "System prompt...", rows: "10" }, - }); - promptTextarea.value = agent.body; - promptTextarea.addEventListener("input", () => { state.systemPrompt = promptTextarea.value; }); - - // ─── Runtime Config Section ─── - const configSection = form.createDiv({ cls: "af-create-section" }); - const configHeader = configSection.createDiv({ cls: "af-create-section-header" }); - const configIcon = configHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(configIcon, "settings"); - configHeader.createSpan({ text: "Runtime Config" }); - - const configGrid = configSection.createDiv({ cls: "af-create-config-grid" }); - - // Adapter (before model) - const adapterRow = configGrid.createDiv({ cls: "af-form-row" }); - adapterRow.createDiv({ cls: "af-form-label", text: "Adapter" }); - const adapterSelect = adapterRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl, disabled] of ADAPTER_FORM_OPTIONS) { - const opt = adapterSelect.createEl("option", { text: lbl, attr: { value: val, ...(disabled ? { disabled: "true" } : {}) } }); - if (val === agent.adapter || (isCodexAdapterValue(agent.adapter) && val === "codex")) opt.selected = true; - } - - // Model - const modelRow = configGrid.createDiv({ cls: "af-form-row" }); - const modelLabel = modelRow.createDiv({ cls: "af-form-label", text: "Model" }); - this.addTooltip( - modelLabel, - `Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom\u2026 for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${this.plugin.settings.defaultModel || "CLI default"}).`, - ); - const modelFieldWrap = modelRow.createDiv({ cls: "af-form-field-wrap" }); - const renderEditModelField = () => { - renderModelPicker(modelFieldWrap, { - value: state.model, - adapter: state.adapter, - onChange: (value) => { state.model = value; }, - }); - }; - renderEditModelField(); - - let repopulateEditPermModeSelect: () => void = () => { /* assigned below */ }; - - adapterSelect.addEventListener("change", () => { - state.adapter = adapterSelect.value; - const otherAliases = isCodexAdapterValue(state.adapter) ? MODEL_ALIASES : CODEX_MODEL_ALIASES; - if (otherAliases.some((a) => a.value === state.model.trim())) { - state.model = ""; - } - renderEditModelField(); - state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); - repopulateEditPermModeSelect(); - }); - - // Working Dir - const cwdRow = configGrid.createDiv({ cls: "af-form-row" }); - cwdRow.createDiv({ cls: "af-form-label", text: "Working Dir" }); - const cwdInput = cwdRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", placeholder: "Leave empty for vault root", value: agent.cwd ?? "" }, - }); - cwdInput.addEventListener("input", () => { state.cwd = cwdInput.value; }); - - // Timeout - const timeoutRow = configGrid.createDiv({ cls: "af-form-row" }); - timeoutRow.createDiv({ cls: "af-form-label", text: "Timeout (sec)" }); - const timeoutInput = timeoutRow.createEl("input", { - cls: "af-form-input af-form-input-sm", - attr: { type: "number", value: String(agent.timeout) }, - }); - timeoutInput.addEventListener("input", () => { - const n = parseInt(timeoutInput.value, 10); - if (!isNaN(n) && n > 0) state.timeout = n; - }); - - // Permission Mode (options depend on the selected adapter) - const permRow = configGrid.createDiv({ cls: "af-form-row" }); - permRow.createDiv({ cls: "af-form-label", text: "Permission Mode" }); - const permSelect = permRow.createEl("select", { cls: "af-form-select" }); - const editPermDescEl = configGrid.createDiv({ cls: "af-form-hint", text: "" }); - repopulateEditPermModeSelect = () => { - // Snap any value left over from the other adapter family to its - // nearest equivalent so the dropdown always reflects what gets saved. - state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); - const options = permModeOptionsFor(state.adapter); - permSelect.empty(); - for (const [val, lbl] of options) { - const opt = permSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === state.permissionMode) opt.selected = true; - } - editPermDescEl.textContent = options.find(([v]) => v === permSelect.value)?.[2] ?? ""; - }; - repopulateEditPermModeSelect(); - permSelect.addEventListener("change", () => { - state.permissionMode = permSelect.value; - editPermDescEl.textContent = permModeOptionsFor(state.adapter).find(([v]) => v === permSelect.value)?.[2] ?? ""; - }); - - // Effort Level - const editEffortRow = configGrid.createDiv({ cls: "af-form-row" }); - editEffortRow.createDiv({ cls: "af-form-label", text: "Effort Level" }); - const editEffortSelect = editEffortRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl] of [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) { - const opt = editEffortSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === (agent.effort ?? "")) opt.selected = true; - } - editEffortSelect.addEventListener("change", () => { state.effort = editEffortSelect.value; }); - configGrid.createDiv({ cls: "af-form-hint", text: "Controls reasoning depth — low is fast, max is most thorough" }); - - // Auto-compact threshold - const editCompactRow = configGrid.createDiv({ cls: "af-form-row" }); - const editCompactLabel = editCompactRow.createDiv({ cls: "af-form-label", text: "Auto-compact at" }); - this.addTooltip( - editCompactLabel, - "Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.", - ); - const editCompactInput = editCompactRow.createEl("input", { - cls: "af-form-input af-form-input-sm", - attr: { type: "number", min: "0", max: "100", value: String(state.autoCompactThreshold) }, - }); - editCompactInput.addEventListener("input", () => { - const n = parseInt(editCompactInput.value, 10); - if (!isNaN(n) && n >= 0 && n <= 100) state.autoCompactThreshold = n; - }); - configGrid.createDiv({ cls: "af-form-hint", text: "0 disables auto-compact" }); - - // Wiki references - { - const wikiKeepers = this.plugin.runtime.getSnapshot().agents.filter((a) => a.wikiKeeper !== undefined); - if (wikiKeepers.length > 0) { - const wrRow = configGrid.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const wrLabel = wrRow.createDiv({ cls: "af-form-label", text: "Wiki access" }); - this.addTooltip( - wrLabel, - "Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).", - ); - const wrWrap = wrRow.createDiv({ cls: "af-form-field-wrap" }); - for (const wk of wikiKeepers) { - const label = wrWrap.createEl("label", { cls: "af-form-checkbox-row" }); - const cb = label.createEl("input", { attr: { type: "checkbox" } }); - if (state.wikiReferences.includes(wk.name)) cb.checked = true; - label.createSpan({ text: ` ${wk.name}`, cls: "af-form-checkbox-label" }); - cb.addEventListener("change", () => { - if (cb.checked) { - if (!state.wikiReferences.includes(wk.name)) state.wikiReferences.push(wk.name); - } else { - state.wikiReferences = state.wikiReferences.filter((n) => n !== wk.name); - } - }); - } - } - } - - // ─── Heartbeat Section ─── - if (agent.isFolder) { - const heartbeatSection = form.createDiv({ cls: "af-create-section" }); - const heartbeatHeader = heartbeatSection.createDiv({ cls: "af-create-section-header" }); - const heartbeatIcon = heartbeatHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(heartbeatIcon, "heart-pulse"); - const heartbeatHeaderLabel = heartbeatHeader.createSpan({ text: "Heartbeat" }); - this.addTooltip(heartbeatHeaderLabel, "Autonomous periodic run — what the agent does when no one is asking"); - - const hbEnabledRow = heartbeatSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - hbEnabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const hbEnabledToggle = hbEnabledRow.createDiv({ cls: `af-agent-card-toggle${state.heartbeatEnabled ? " on" : ""}` }); - const hbBody = heartbeatSection.createDiv(); - hbBody.setCssStyles({ display: state.heartbeatEnabled ? "" : "none" }); - hbEnabledToggle.onclick = () => { - const isOn = hbEnabledToggle.hasClass("on"); - hbEnabledToggle.toggleClass("on", !isOn); - state.heartbeatEnabled = !isOn; - hbBody.setCssStyles({ display: !isOn ? "" : "none" }); - }; - - this.renderHeartbeatSchedule(hbBody, state); - - const hbNotifyRow = hbBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const hbNotifyLabel = hbNotifyRow.createDiv({ cls: "af-form-label" }); - hbNotifyLabel.setText("Notify"); - this.addTooltip(hbNotifyLabel, "Show an Obsidian notice when the heartbeat completes"); - const hbNotifyToggle = hbNotifyRow.createDiv({ cls: `af-agent-card-toggle${state.heartbeatNotify ? " on" : ""}` }); - hbNotifyToggle.onclick = () => { - const isOn = hbNotifyToggle.hasClass("on"); - hbNotifyToggle.toggleClass("on", !isOn); - state.heartbeatNotify = !isOn; - }; - - const editSnapshot = this.plugin.runtime.getSnapshot(); - const hbChannelRow = hbBody.createDiv({ cls: "af-form-row" }); - const hbChannelLabel = hbChannelRow.createDiv({ cls: "af-form-label" }); - hbChannelLabel.setText("Post to channel"); - this.addTooltip(hbChannelLabel, "Heartbeat results are posted to this Slack channel when the run completes"); - const hbChannelSelect = hbChannelRow.createEl("select", { cls: "af-form-select" }); - hbChannelSelect.createEl("option", { text: "(none)", attr: { value: "" } }); - for (const ch of editSnapshot.channels) { - const opt = hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } }); - if (ch.name === state.heartbeatChannel) opt.selected = true; - } - hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); }); - - const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" }); - const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" }); - hbTargetLabel.setText("Target ID"); - this.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel’s first allowed user."); - const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } }); - hbTargetInput.value = state.heartbeatChannelTarget; - hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); }); - const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); }; - syncHbTarget(); - - const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" }); - hbInstructionLabel.setCssStyles({ width: "auto" }); - hbInstructionLabel.setCssStyles({ marginTop: "12px" }); - hbInstructionLabel.setText("Instruction"); - this.addTooltip(hbInstructionLabel, "What the agent does on each heartbeat. Also used by the \"Run Now\" button."); - const hbTextarea = hbBody.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Check status, scan for issues, report findings...", rows: "8" }, - }); - hbTextarea.value = state.heartbeatBody; - hbTextarea.addEventListener("input", () => { state.heartbeatBody = hbTextarea.value; }); - } - - // ─── Skills Section ─── - const skillsSection = form.createDiv({ cls: "af-create-section" }); - const skillsHeader = skillsSection.createDiv({ cls: "af-create-section-header" }); - const skillsIcon = skillsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(skillsIcon, "puzzle"); - skillsHeader.createSpan({ text: "Skills" }); - - const snapshot = this.plugin.runtime.getSnapshot(); - if (snapshot.skills.length > 0) { - skillsSection.createDiv({ cls: "af-form-sublabel", text: "Shared Skills" }); - const skillsGrid = skillsSection.createDiv({ cls: "af-create-skills-grid" }); - for (const skill of snapshot.skills) { - const item = skillsGrid.createDiv({ cls: "af-create-skill-item" }); - const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } }); - cb.checked = state.selectedSkills.has(skill.name); - cb.addEventListener("change", () => { - if (cb.checked) state.selectedSkills.add(skill.name); - else state.selectedSkills.delete(skill.name); - }); - const lbl = item.createDiv({ cls: "af-create-skill-label" }); - lbl.createSpan({ cls: "af-create-skill-name", text: skill.name }); - if (skill.description) { - lbl.createSpan({ cls: "af-create-skill-desc", text: ` — ${skill.description}` }); - } - } - } - - const agentSkillsLabel = skillsSection.createDiv({ cls: "af-form-sublabel" }); - agentSkillsLabel.setText("Agent-specific skills"); - this.addTooltip(agentSkillsLabel, "Custom skills/instructions only for this agent, not shared with others"); - const skillsTextarea = skillsSection.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Custom skills/instructions for this agent...", rows: "4" }, - }); - skillsTextarea.value = agent.skillsBody; - skillsTextarea.addEventListener("input", () => { state.skillsBody = skillsTextarea.value; }); - - // ─── MCP Servers Section ─── - const mcpSection = form.createDiv({ cls: "af-create-section" }); - const mcpHeader = mcpSection.createDiv({ cls: "af-create-section-header" }); - const mcpIcon = mcpHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(mcpIcon, "plug"); - const editMcpHeaderLabel = mcpHeader.createSpan({ text: "MCP Servers" }); - this.addTooltip(editMcpHeaderLabel, "Grant agent access to MCP servers"); - this.renderAgentMcpPicker(mcpSection, state.selectedMcpServers); - - // ─── Context Section ─── - const contextSection = form.createDiv({ cls: "af-create-section" }); - const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); - const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(contextIcon, "file-text"); - const contextHeaderLabel = contextHeader.createSpan({ text: "Context" }); - this.addTooltip(contextHeaderLabel, "Project-specific context included in every run"); - const contextTextarea = contextSection.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Background info, repo structure, conventions...", rows: "4" }, - }); - contextTextarea.value = agent.contextBody; - contextTextarea.addEventListener("input", () => { state.contextBody = contextTextarea.value; }); - - // ─── Permissions Section ─── - const permsSection = form.createDiv({ cls: "af-create-section" }); - const permsHeader = permsSection.createDiv({ cls: "af-create-section-header" }); - const permsIcon = permsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(permsIcon, "shield-check"); - permsHeader.createSpan({ text: "Permissions" }); - - this.createFormField(permsSection, "Approval required", "git_push, file_delete", "Comma-separated tool names", (v) => { state.approvalRequired = v; }, agent.approvalRequired.join(", ")); - - const editAllowRow = permsSection.createDiv({ cls: "af-form-row" }); - editAllowRow.createDiv({ cls: "af-form-label", text: "Allowed Commands" }); - const editAllowTextarea = editAllowRow.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Bash(curl *)\nBash(python3 *)\nRead\nEdit\nWrite", rows: "4" }, - }); - editAllowTextarea.value = agent.permissionRules.allow.join("\n"); - editAllowTextarea.addEventListener("input", () => { state.allowedCommands = editAllowTextarea.value; }); - - const editDenyRow = permsSection.createDiv({ cls: "af-form-row" }); - editDenyRow.createDiv({ cls: "af-form-label", text: "Blocked Commands" }); - const editDenyTextarea = editDenyRow.createEl("textarea", { - cls: "af-create-textarea", - attr: { placeholder: "Bash(git push *)\nBash(rm -rf *)\nBash(sudo *)", rows: "4" }, - }); - editDenyTextarea.value = agent.permissionRules.deny.join("\n"); - editDenyTextarea.addEventListener("input", () => { state.blockedCommands = editDenyTextarea.value; }); - - permsSection.createDiv({ - cls: "af-form-hint", - text: - "On Codex agents these become execpolicy command rules — only Bash(cmd args *) " + - "prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are " + - "ignored, and file/network access is governed by Permission Mode (the sandbox).", - }); - - const memoryRow = permsSection.createDiv({ cls: "af-form-row" }); - memoryRow.createDiv({ cls: "af-form-label", text: "Memory enabled" }); - const memoryToggle = memoryRow.createDiv({ cls: `af-agent-card-toggle${agent.memory ? " on" : ""}` }); - memoryToggle.onclick = () => { - const isOn = memoryToggle.hasClass("on"); - memoryToggle.toggleClass("on", !isOn); - state.memory = !isOn; - }; - - const budgetRow = permsSection.createDiv({ cls: "af-form-row" }); - budgetRow.createDiv({ cls: "af-form-label", text: "Memory token budget" }); - const budgetInput = budgetRow.createEl("input", { - cls: "af-create-input", - attr: { type: "number", min: "200", step: "100" }, - }); - budgetInput.value = String(state.memoryTokenBudget); - budgetInput.addEventListener("input", () => { - const v = parseInt(budgetInput.value, 10); - if (Number.isFinite(v)) state.memoryTokenBudget = v; - }); - - const reflectRow = permsSection.createDiv({ cls: "af-form-row" }); - reflectRow.createDiv({ cls: "af-form-label", text: "Nightly reflection" }); - const reflectToggle = reflectRow.createDiv({ - cls: `af-agent-card-toggle${agent.reflection.enabled ? " on" : ""}`, - }); - reflectToggle.onclick = () => { - const isOn = reflectToggle.hasClass("on"); - reflectToggle.toggleClass("on", !isOn); - state.reflectionEnabled = !isOn; - }; - - const proposeRow = permsSection.createDiv({ cls: "af-form-row" }); - proposeRow.createDiv({ cls: "af-form-label", text: "Propose skills from memory" }); - const proposeToggle = proposeRow.createDiv({ - cls: `af-agent-card-toggle${agent.reflection.proposeSkills ? " on" : ""}`, - }); - proposeToggle.onclick = () => { - const isOn = proposeToggle.hasClass("on"); - proposeToggle.toggleClass("on", !isOn); - state.reflectionProposeSkills = !isOn; - }; - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - - const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); - createIcon(deleteBtn, "trash-2", "af-btn-icon"); - deleteBtn.appendText(" Delete"); - deleteBtn.onclick = () => void this.plugin.deleteAgent(agent.name); - - footer.createDiv({ cls: "af-toolbar-spacer" }); - - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("agent-detail", agent.name); - - const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(saveBtn, "check", "af-btn-icon"); - saveBtn.appendText(" Save Changes"); - saveBtn.onclick = async () => { - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - try { - const parseLines = (s: string) => splitLines(s).map((l) => l.trim()).filter(Boolean); - await this.plugin.repository.updateAgent(agent.name, { - description: state.description.trim(), - avatar: state.avatar.trim(), - tags: parseTags(state.tags), - systemPrompt: state.systemPrompt.trim(), - model: state.model.trim() || "default", - adapter: state.adapter, - cwd: state.cwd.trim(), - timeout: state.timeout, - permissionMode: state.permissionMode, - effort: state.effort || undefined, - approvalRequired: parseTags(state.approvalRequired), - memory: state.memory, - memoryTokenBudget: state.memoryTokenBudget, - reflectionEnabled: state.reflectionEnabled, - reflectionProposeSkills: state.reflectionProposeSkills, - skills: Array.from(state.selectedSkills), - mcpServers: Array.from(state.selectedMcpServers), - skillsBody: state.skillsBody.trim(), - contextBody: state.contextBody.trim(), - enabled: state.enabled, - permissionRules: { - allow: parseLines(state.allowedCommands), - deny: parseLines(state.blockedCommands), - }, - autoCompactThreshold: state.autoCompactThreshold, - wikiReferences: state.wikiReferences, - }); - // Persist heartbeat separately (lives in HEARTBEAT.md, not agent.md/config.md) - if (agent.isFolder) { - await this.plugin.repository.updateHeartbeat(agent.name, { - enabled: state.heartbeatEnabled, - schedule: state.heartbeatSchedule.trim(), - notify: state.heartbeatNotify, - channel: state.heartbeatChannel, - channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "", - body: state.heartbeatBody.trim(), - }); - } - - new Notice(`Agent "${agent.name}" updated.`); - await this.plugin.refreshFromVault(); - this.navigate("agent-detail", agent.name); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to update agent: ${msg}`); - } - }; - } - - /** - * Render the optional "post results to a channel" controls shared by the - * create and edit task forms: a channel picker plus a transport-native target - * id (Discord/Slack channel id, Telegram chat id). Empty target = broadcast/DM. - */ - private renderTaskChannelDelivery( - section: HTMLElement, - channels: ChannelConfig[], - state: { channel: string; channelTarget: string }, - ): void { - const channelRow = section.createDiv({ cls: "af-form-row" }); - const channelLabel = channelRow.createDiv({ cls: "af-form-label", text: "Channel" }); - const channelSelect = channelRow.createEl("select", { cls: "af-form-select" }); - channelSelect.createEl("option", { text: "— none (run log only) —", attr: { value: "" } }); - for (const ch of channels) { - const opt = channelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } }); - if (ch.name === state.channel) opt.selected = true; - } - this.addTooltip( - channelLabel, - "Post this task’s full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).", - ); - - const targetRow = section.createDiv({ cls: "af-form-row" }); - const targetLabel = targetRow.createDiv({ cls: "af-form-label", text: "Target ID" }); - const targetInput = targetRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", placeholder: "Discord/Slack channel ID or Telegram chat ID — empty = DM you" }, - }); - targetInput.value = state.channelTarget; - targetInput.addEventListener("input", () => { state.channelTarget = targetInput.value.trim(); }); - this.addTooltip( - targetLabel, - "Where in the channel to post. For Discord, enable Developer Mode and right-click the channel → Copy Channel ID. Leave empty to DM the first allowed user instead.", - ); - - const syncVisibility = () => { targetRow.setCssStyles({ display: state.channel ? "" : "none" }); }; - syncVisibility(); - channelSelect.addEventListener("change", () => { - state.channel = channelSelect.value; - syncVisibility(); - }); + renderEditAgentForm(page, this.agentFormDeps(), agent); } // ═══════════════════════════════════════════════════════ @@ -4834,296 +2043,7 @@ export class FleetDashboardView extends ItemView { private renderCreateTaskPage(container: HTMLElement): void { const page = container.createDiv({ cls: "af-create-agent-page" }); - const snapshot = this.plugin.runtime.getSnapshot(); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatar, "plus"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Task" }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new task for your fleet" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - - // Form state - const state = { - title: "", - agent: snapshot.agents[0]?.name ?? "", - priority: "medium", - tags: "", - body: "", - scheduleEnabled: false, - /** "recurring" (cron) or "once" (run_at). Only consulted when - * scheduleEnabled is true; otherwise the task is "immediate". */ - scheduleMode: "recurring" as "recurring" | "once", - schedule: "0 9 * * *", - runAt: "", - type: "immediate" as string, - enabled: true, - catchUp: true, - effort: "", - model: "", - channel: "", - channelTarget: "", - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Task Details Section ─── - const detailsSection = form.createDiv({ cls: "af-create-section" }); - const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); - const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(detailsIcon, "file-text"); - detailsHeader.createSpan({ text: "Task Details" }); - - this.createFormField(detailsSection, "Title", "Daily status report", "Used as the task identifier", (v) => { state.title = v; }); - - // Agent dropdown - const agentRow = detailsSection.createDiv({ cls: "af-form-row" }); - agentRow.createDiv({ cls: "af-form-label", text: "Agent" }); - const agentSelect = agentRow.createEl("select", { cls: "af-form-select" }); - for (const a of snapshot.agents) { - agentSelect.createEl("option", { text: a.name, attr: { value: a.name } }); - } - agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; }); - - // Priority dropdown - const priorityRow = detailsSection.createDiv({ cls: "af-form-row" }); - priorityRow.createDiv({ cls: "af-form-label", text: "Priority" }); - const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" }); - const priorityOptions: Array<[string, string]> = [ - ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"], - ]; - for (const [val, lbl] of priorityOptions) { - const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === "medium") opt.selected = true; - } - prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value; }); - - // Tags - this.createFormField(detailsSection, "Tags", "monitoring, devops", "Comma-separated", (v) => { state.tags = v; }); - - // ─── Instructions Section ─── - const instructionsSection = form.createDiv({ cls: "af-create-section" }); - const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); - const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(instructionsIcon, "message-square"); - instructionsHeader.createSpan({ text: "Instructions" }); - - const instructionsTextarea = instructionsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Describe what the agent should do...", rows: "10" }, - }); - instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; }); - - // ─── Schedule Section ─── - const scheduleSection = form.createDiv({ cls: "af-create-section" }); - const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" }); - const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(scheduleIcon, "clock"); - scheduleHeader.createSpan({ text: "Schedule" }); - - // Enable schedule toggle - const scheduleToggleRow = scheduleSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - scheduleToggleRow.createDiv({ cls: "af-form-label", text: "Enable schedule" }); - const scheduleToggle = scheduleToggleRow.createDiv({ cls: "af-agent-card-toggle" }); - const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" }); - scheduleBody.setCssStyles({ display: "none" }); - - scheduleToggle.onclick = () => { - const isOn = scheduleToggle.hasClass("on"); - scheduleToggle.toggleClass("on", !isOn); - state.scheduleEnabled = !isOn; - scheduleBody.setCssStyles({ display: !isOn ? "" : "none" }); - if (!isOn) { - state.type = state.scheduleMode === "once" ? "once" : "recurring"; - } else { - state.type = "immediate"; - } - }; - - // Schedule mode selector (recurring vs one-time) - const modeRow = scheduleBody.createDiv({ cls: "af-form-row" }); - modeRow.createDiv({ cls: "af-form-label", text: "Mode" }); - const modeSelect = modeRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl] of [["recurring", "Recurring"], ["once", "One-time"]] as const) { - modeSelect.createEl("option", { text: lbl, attr: { value: val } }); - } - const cronHost = scheduleBody.createDiv(); - const onceHost = scheduleBody.createDiv(); - onceHost.setCssStyles({ display: "none" }); - this.renderInlineSchedule(cronHost, state); - - const onceRow = onceHost.createDiv({ cls: "af-form-row" }); - onceRow.createDiv({ cls: "af-form-label", text: "Run at" }); - const onceInput = onceRow.createEl("input", { - cls: "af-form-input", - attr: { type: "datetime-local", value: this.toDatetimeLocal(new Date(Date.now() + 3600_000)) }, - }); - // Seed the initial value — the task won't persist blank runAt - state.runAt = new Date(onceInput.value).toISOString(); - onceInput.addEventListener("input", () => { - state.runAt = onceInput.value ? new Date(onceInput.value).toISOString() : ""; - }); - - modeSelect.addEventListener("change", () => { - state.scheduleMode = modeSelect.value as "recurring" | "once"; - cronHost.setCssStyles({ display: state.scheduleMode === "recurring" ? "" : "none" }); - onceHost.setCssStyles({ display: state.scheduleMode === "once" ? "" : "none" }); - if (state.scheduleEnabled) state.type = state.scheduleMode === "once" ? "once" : "recurring"; - }); - - // Enabled toggle (only when schedule is on) - const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); - enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" }); - enabledToggle.onclick = () => { - const isOn = enabledToggle.hasClass("on"); - enabledToggle.toggleClass("on", !isOn); - state.enabled = !isOn; - }; - - // Catch up missed runs toggle - const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" }); - catchUpLabel.setText("Catch up if missed"); - - const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` }); - catchUpToggle.onclick = () => { - const isOn = catchUpToggle.hasClass("on"); - catchUpToggle.toggleClass("on", !isOn); - state.catchUp = !isOn; - }; - - // ─── Execution Section ─── - // Model & effort are per-run controls, not scheduling knobs — they apply - // whether or not the task has a schedule, so they live in their own section. - const execSection = form.createDiv({ cls: "af-create-section" }); - const execHeader = execSection.createDiv({ cls: "af-create-section-header" }); - const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(execIcon, "gauge"); - execHeader.createSpan({ text: "Execution" }); - - // Model override (per-task) - const taskModelRow = execSection.createDiv({ cls: "af-form-row" }); - const taskModelLabel = taskModelRow.createDiv({ cls: "af-form-label", text: "Model" }); - const taskModelFieldWrap = taskModelRow.createDiv({ cls: "af-form-field-wrap" }); - const renderCreateTaskModelPicker = (agentName: string) => { - taskModelFieldWrap.empty(); - const selAgent = snapshot.agents.find((a) => a.name === agentName); - renderModelPicker(taskModelFieldWrap, { - value: state.model, - adapter: selAgent?.adapter, - onChange: (value) => { state.model = value; }, - allowInherit: true, - inheritPlaceholder: selAgent - ? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}` - : "Inherit from agent", - }); - }; - renderCreateTaskModelPicker(state.agent); - agentSelect.addEventListener("change", () => renderCreateTaskModelPicker(agentSelect.value)); - this.addTooltip( - taskModelLabel, - "Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.", - ); - - // Effort level - const taskEffortRow = execSection.createDiv({ cls: "af-form-row" }); - const taskEffortLabel = taskEffortRow.createDiv({ cls: "af-form-label", text: "Effort" }); - const taskEffortSelect = taskEffortRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl] of [["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) { - const opt = taskEffortSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === state.effort) opt.selected = true; - } - taskEffortSelect.addEventListener("change", () => { state.effort = taskEffortSelect.value; }); - // Channel delivery (optional) — post this task's output to a channel on completion. - this.renderTaskChannelDelivery(execSection, snapshot.channels, state); - this.addTooltip( - taskEffortLabel, - "Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.", - ); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("kanban"); - - const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(createBtn, "plus", "af-btn-icon"); - createBtn.appendText(" Create Task"); - createBtn.onclick = async () => { - const title = state.title.trim(); - if (!title) { - new Notice("Task title is required."); - return; - } - const taskId = slugify(title); - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - - const effectiveType = state.scheduleEnabled - ? state.scheduleMode === "once" - ? "once" - : "recurring" - : "immediate"; - - const frontmatter: Record = { - task_id: taskId, - agent: state.agent, - type: effectiveType, - priority: state.priority, - enabled: state.enabled, - created: this.toLocalISO(new Date()), - run_count: 0, - catch_up: state.catchUp, - effort: state.effort || undefined, - model: state.model || undefined, - channel: state.channel || undefined, - channel_target: state.channel && state.channelTarget ? state.channelTarget : undefined, - tags: parseTags(state.tags), - }; - - if (effectiveType === "recurring") { - frontmatter.schedule = state.schedule.trim() || "0 9 * * *"; - } else if (effectiveType === "once") { - if (!state.runAt) { - new Notice("Pick a date/time for the one-time run."); - return; - } - frontmatter.run_at = state.runAt; - } - - try { - const path = await this.plugin.repository.getAvailablePath( - this.plugin.repository.getSubfolder("tasks"), - taskId, - ); - await this.plugin.app.vault.create( - path, - stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim() || "Describe the task here."), - ); - new Notice(`Task "${taskId}" created.`); - await this.plugin.refreshFromVault(); - this.navigate("task-detail", taskId); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to create task: ${msg}`); - } - }; - } - - private toLocalISO(date: Date): string { - const pad = (n: number) => String(n).padStart(2, "0"); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - } - - /** Format a Date as the value string accepted by ``: - * `YYYY-MM-DDTHH:mm`, local timezone, no seconds. */ - private toDatetimeLocal(date: Date): string { - const pad = (n: number) => String(n).padStart(2, "0"); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + renderCreateTaskForm(page, this.taskFormDeps()); } // ═══════════════════════════════════════════════════════ @@ -5144,288 +2064,7 @@ export class FleetDashboardView extends ItemView { return; } - const snapshot = this.plugin.runtime.getSnapshot(); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(taskIcon, "edit"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Task: ${task.taskId}` }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify task configuration" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - - const hasSchedule = !!(task.schedule || task.runAt); - - // Form state pre-filled - const state = { - agent: task.agent, - type: task.type as string, - priority: task.priority, - schedule: task.schedule ?? "0 9 * * *", - runAt: task.runAt ?? "", - scheduleEnabled: hasSchedule, - scheduleMode: (task.type === "once" ? "once" : "recurring"), - enabled: task.enabled, - catchUp: task.catchUp, - effort: task.effort ?? "", - model: task.model ?? "", - channel: task.channel ?? "", - channelTarget: task.channelTarget ?? "", - tags: task.tags.join(", "), - body: task.body, - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Task Details Section ─── - const detailsSection = form.createDiv({ cls: "af-create-section" }); - const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); - const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(detailsIcon, "file-text"); - detailsHeader.createSpan({ text: "Task Details" }); - - // Title (read-only) - const nameRow = detailsSection.createDiv({ cls: "af-form-row" }); - nameRow.createDiv({ cls: "af-form-label", text: "Title" }); - const nameInput = nameRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", value: task.taskId, disabled: "true" }, - }); - nameInput.setCssStyles({ opacity: "0.6" }); - - // Agent dropdown - const agentRow = detailsSection.createDiv({ cls: "af-form-row" }); - agentRow.createDiv({ cls: "af-form-label", text: "Agent" }); - const agentSelect = agentRow.createEl("select", { cls: "af-form-select" }); - for (const a of snapshot.agents) { - const opt = agentSelect.createEl("option", { text: a.name, attr: { value: a.name } }); - if (a.name === task.agent) opt.selected = true; - } - agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; }); - - // Priority dropdown - const priorityRow = detailsSection.createDiv({ cls: "af-form-row" }); - priorityRow.createDiv({ cls: "af-form-label", text: "Priority" }); - const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" }); - const priorityOptions: Array<[string, string]> = [ - ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"], - ]; - for (const [val, lbl] of priorityOptions) { - const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === task.priority) opt.selected = true; - } - prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value as typeof state.priority; }); - - // Tags - this.createFormField(detailsSection, "Tags", "monitoring, critical", "Comma-separated", (v) => { state.tags = v; }, task.tags.join(", ")); - - // ─── Instructions Section ─── - const instructionsSection = form.createDiv({ cls: "af-create-section" }); - const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); - const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(instructionsIcon, "message-square"); - instructionsHeader.createSpan({ text: "Instructions" }); - - const instructionsTextarea = instructionsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Describe what the agent should do...", rows: "10" }, - }); - instructionsTextarea.value = task.body; - instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; }); - - // ─── Schedule Section ─── - const scheduleSection = form.createDiv({ cls: "af-create-section" }); - const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" }); - const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(scheduleIcon, "clock"); - scheduleHeader.createSpan({ text: "Schedule" }); - - // Enable schedule toggle - const scheduleToggleRow = scheduleSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); - scheduleToggleRow.createDiv({ cls: "af-form-label", text: "Enable schedule" }); - const scheduleToggle = scheduleToggleRow.createDiv({ cls: `af-agent-card-toggle${hasSchedule ? " on" : ""}` }); - const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" }); - scheduleBody.setCssStyles({ display: hasSchedule ? "" : "none" }); - - scheduleToggle.onclick = () => { - const isOn = scheduleToggle.hasClass("on"); - scheduleToggle.toggleClass("on", !isOn); - state.scheduleEnabled = !isOn; - scheduleBody.setCssStyles({ display: !isOn ? "" : "none" }); - if (!isOn) { - state.type = state.scheduleMode === "once" ? "once" : "recurring"; - } else { - state.type = "immediate"; - } - }; - - // Schedule mode selector - const editModeRow = scheduleBody.createDiv({ cls: "af-form-row" }); - editModeRow.createDiv({ cls: "af-form-label", text: "Mode" }); - const editModeSelect = editModeRow.createEl("select", { cls: "af-form-select" }); - for (const [val, lbl] of [["recurring", "Recurring"], ["once", "One-time"]] as const) { - const opt = editModeSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === state.scheduleMode) opt.selected = true; - } - const editCronHost = scheduleBody.createDiv(); - const editOnceHost = scheduleBody.createDiv(); - editCronHost.setCssStyles({ display: state.scheduleMode === "recurring" ? "" : "none" }); - editOnceHost.setCssStyles({ display: state.scheduleMode === "once" ? "" : "none" }); - this.renderInlineSchedule(editCronHost, state); - - const editOnceRow = editOnceHost.createDiv({ cls: "af-form-row" }); - editOnceRow.createDiv({ cls: "af-form-label", text: "Run at" }); - const prefillOnce = state.runAt - ? this.toDatetimeLocal(new Date(state.runAt)) - : this.toDatetimeLocal(new Date(Date.now() + 3600_000)); - const editOnceInput = editOnceRow.createEl("input", { - cls: "af-form-input", - attr: { type: "datetime-local", value: prefillOnce }, - }); - if (!state.runAt) state.runAt = new Date(editOnceInput.value).toISOString(); - editOnceInput.addEventListener("input", () => { - state.runAt = editOnceInput.value ? new Date(editOnceInput.value).toISOString() : ""; - }); - - editModeSelect.addEventListener("change", () => { - state.scheduleMode = editModeSelect.value; - editCronHost.setCssStyles({ display: state.scheduleMode === "recurring" ? "" : "none" }); - editOnceHost.setCssStyles({ display: state.scheduleMode === "once" ? "" : "none" }); - if (state.scheduleEnabled) state.type = state.scheduleMode === "once" ? "once" : "recurring"; - }); - - // Enabled toggle (whether the schedule is active) - const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); - enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); - const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${task.enabled ? " on" : ""}` }); - enabledToggle.onclick = () => { - const isOn = enabledToggle.hasClass("on"); - enabledToggle.toggleClass("on", !isOn); - state.enabled = !isOn; - }; - - // Catch up missed runs toggle - const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); - const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" }); - catchUpLabel.setText("Catch up if missed"); - - const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` }); - catchUpToggle.onclick = () => { - const isOn = catchUpToggle.hasClass("on"); - catchUpToggle.toggleClass("on", !isOn); - state.catchUp = !isOn; - }; - - // ─── Execution Section ─── - const execSection = form.createDiv({ cls: "af-create-section" }); - const execHeader = execSection.createDiv({ cls: "af-create-section-header" }); - const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(execIcon, "gauge"); - execHeader.createSpan({ text: "Execution" }); - - // Effort level - const effortRow = execSection.createDiv({ cls: "af-form-row" }); - const effortLabel = effortRow.createDiv({ cls: "af-form-label", text: "Effort" }); - const effortSelect = effortRow.createEl("select", { cls: "af-form-select" }); - const effortOptions: Array<[string, string]> = [ - ["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"], - ]; - for (const [val, lbl] of effortOptions) { - const opt = effortSelect.createEl("option", { text: lbl, attr: { value: val } }); - if (val === state.effort) opt.selected = true; - } - effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; }); - this.addTooltip( - effortLabel, - "Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.", - ); - - // Model override - const modelRowEdit = execSection.createDiv({ cls: "af-form-row" }); - const modelLabelEdit = modelRowEdit.createDiv({ cls: "af-form-label", text: "Model" }); - const modelFieldWrapEdit = modelRowEdit.createDiv({ cls: "af-form-field-wrap" }); - const renderTaskModelPicker = (agentName: string) => { - modelFieldWrapEdit.empty(); - const selAgent = snapshot.agents.find((a) => a.name === agentName); - renderModelPicker(modelFieldWrapEdit, { - value: state.model, - adapter: selAgent?.adapter, - onChange: (value) => { state.model = value; }, - allowInherit: true, - inheritPlaceholder: selAgent - ? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}` - : "Inherit from agent", - }); - }; - renderTaskModelPicker(state.agent); - agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value)); - // Channel delivery (optional) — post this task's output to a channel on completion. - this.renderTaskChannelDelivery(execSection, snapshot.channels, state); - this.addTooltip( - modelLabelEdit, - "Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.", - ); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - - const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); - createIcon(deleteBtn, "trash-2", "af-btn-icon"); - deleteBtn.appendText(" Delete"); - deleteBtn.onclick = async () => { - await this.plugin.repository.deleteTask(task.taskId); - new Notice(`Task "${task.taskId}" deleted.`); - await new Promise((r) => window.setTimeout(r, 200)); - await this.plugin.refreshFromVault(); - this.navigate("kanban"); - }; - - footer.createDiv({ cls: "af-toolbar-spacer" }); - - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("task-detail", task.taskId); - - const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(saveBtn, "check", "af-btn-icon"); - saveBtn.appendText(" Save Changes"); - saveBtn.onclick = async () => { - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - const effectiveType = state.scheduleEnabled - ? state.scheduleMode === "once" - ? "once" - : "recurring" - : "immediate"; - if (effectiveType === "once" && !state.runAt) { - new Notice("Pick a date/time for the one-time run."); - return; - } - try { - await this.plugin.repository.updateTask(task.taskId, { - agent: state.agent, - type: effectiveType, - priority: state.priority, - schedule: effectiveType === "recurring" ? state.schedule.trim() : "", - runAt: effectiveType === "once" ? state.runAt : "", - enabled: state.enabled, - catch_up: state.catchUp, - effort: state.effort || undefined, - model: state.model || "", - channel: state.channel || "", - channelTarget: state.channel && state.channelTarget ? state.channelTarget : "", - tags: parseTags(state.tags), - body: state.body.trim(), - }); - new Notice(`Task "${task.taskId}" updated.`); - await this.plugin.refreshFromVault(); - this.navigate("task-detail", task.taskId); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to update task: ${msg}`); - } - }; + renderEditTaskForm(page, this.taskFormDeps(), task); } // ═══════════════════════════════════════════════════════ @@ -5446,525 +2085,7 @@ export class FleetDashboardView extends ItemView { return; } - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatarEl = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatarEl, "edit"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Skill: ${skill.name}` }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify skill definition" }); - - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - // Form state pre-filled - const state = { - description: skill.description ?? "", - tags: skill.tags.join(", "), - body: skill.body, - toolsBody: skill.toolsBody, - referencesBody: skill.referencesBody, - examplesBody: skill.examplesBody, - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Identity Section ─── - const identitySection = form.createDiv({ cls: "af-create-section" }); - const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); - const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(identityIcon, "puzzle"); - identityHeader.createSpan({ text: "Identity" }); - - // Name (read-only) - const nameRow = identitySection.createDiv({ cls: "af-form-row" }); - nameRow.createDiv({ cls: "af-form-label", text: "Name" }); - const nameInput = nameRow.createEl("input", { - cls: "af-form-input", - attr: { type: "text", value: skill.name, disabled: "true" }, - }); - nameInput.setCssStyles({ opacity: "0.6" }); - - this.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; }, skill.description ?? ""); - this.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; }, skill.tags.join(", ")); - - // ─── Core Instructions Section ─── - const instructionsSection = form.createDiv({ cls: "af-create-section" }); - const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); - const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(instructionsIcon, "file-text"); - instructionsHeader.createSpan({ text: "Core Instructions" }); - - const bodyTextarea = instructionsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "Skill instructions...", rows: "10" }, - }); - bodyTextarea.value = skill.body; - bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; }); - - // ─── Tools Section ─── - const toolsSection = form.createDiv({ cls: "af-create-section" }); - const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" }); - const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(toolsIcon, "wrench"); - const editToolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" }); - this.addTooltip(editToolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill"); - const toolsTextarea = toolsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "## Commands\n\n### list\n...", rows: "8" }, - }); - toolsTextarea.value = skill.toolsBody; - toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; }); - - // ─── References Section ─── - const refsSection = form.createDiv({ cls: "af-create-section" }); - const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" }); - const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(refsIcon, "book-open"); - const refsHeaderLabel = refsHeader.createSpan({ text: "References" }); - this.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets"); - const refsTextarea = refsSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" }, - }); - refsTextarea.value = skill.referencesBody; - refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; }); - - // ─── Examples Section ─── - const examplesSection = form.createDiv({ cls: "af-create-section" }); - const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" }); - const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(examplesIcon, "message-circle"); - const editExamplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" }); - this.addTooltip(editExamplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill"); - const examplesTextarea = examplesSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "## Example: List all tasks\n...", rows: "6" }, - }); - examplesTextarea.value = skill.examplesBody; - examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; }); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - - const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); - createIcon(deleteBtn, "trash-2", "af-btn-icon"); - deleteBtn.appendText(" Delete"); - deleteBtn.onclick = async () => { - await this.plugin.repository.deleteSkill(skill.name); - new Notice(`Skill "${skill.name}" deleted.`); - // Small delay for Obsidian vault cache to process the trash - await new Promise((r) => window.setTimeout(r, 200)); - await this.plugin.refreshFromVault(); - this.navigate("skills"); - }; - - footer.createDiv({ cls: "af-toolbar-spacer" }); - - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("skills"); - - const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(saveBtn, "check", "af-btn-icon"); - saveBtn.appendText(" Save Changes"); - saveBtn.onclick = async () => { - const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); - try { - await this.plugin.repository.updateSkill(skill.name, { - description: state.description.trim(), - tags: parseTags(state.tags), - body: state.body.trim(), - toolsBody: state.toolsBody.trim(), - referencesBody: state.referencesBody.trim(), - examplesBody: state.examplesBody.trim(), - }); - new Notice(`Skill "${skill.name}" updated.`); - await this.plugin.refreshFromVault(); - this.navigate("skills"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to update skill: ${msg}`); - } - }; - } - - // ═══════════════════════════════════════════════════════ - // MCP Servers Page - // ═══════════════════════════════════════════════════════ - - /** Render the per-agent MCP grant picker from the fleet registry. Shared by - * the create- and edit-agent forms. `selected` is the agent's - * `mcpServers` set, mutated in place as checkboxes toggle. */ - private renderAgentMcpPicker(section: HTMLElement, selected: Set): void { - section.createDiv({ - cls: "af-form-hint", - text: - "Servers from the MCP Servers tab. Checked servers are available to this " + - "agent on any adapter (Claude or Codex). Leave all unchecked to grant every enabled server.", - }); - const servers = this.plugin.repository.getMcpServers(); - if (servers.length === 0) { - const hint = section.createDiv({ cls: "af-form-hint" }); - hint.appendText("No MCP servers registered yet. "); - const link = hint.createEl("a", { cls: "af-link", text: "Add one in the MCP Servers tab." }); - link.onclick = (e) => { e.preventDefault(); this.navigate("mcp"); }; - return; - } - const grid = section.createDiv({ cls: "af-create-skills-grid" }); - for (const server of servers) { - const item = grid.createDiv({ cls: "af-mcp-agent-item" }); - const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } }); - cb.checked = selected.has(server.name); - cb.addEventListener("change", () => { - if (cb.checked) selected.add(server.name); - else selected.delete(server.name); - }); - const lbl = item.createDiv({ cls: "af-mcp-agent-label" }); - const nameRow = lbl.createDiv({ cls: "af-mcp-agent-name-row" }); - const dot = nameRow.createSpan({ cls: `af-mcp-status-dot ${server.enabled ? "idle" : "disabled"}` }); - dot.title = server.enabled ? "enabled" : "disabled"; - nameRow.createSpan({ cls: "af-create-skill-name", text: server.name }); - nameRow.createSpan({ cls: "af-mcp-agent-tool-count", text: server.type }); - if (!server.enabled) { - nameRow.createSpan({ cls: "af-mcp-agent-tool-count af-muted", text: "disabled" }); - } - } - } - - private renderMcpPage(container: HTMLElement): void { - const page = container.createDiv({ cls: "af-agents-page" }); - - const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); - toolbar.createDiv({ cls: "af-page-title", text: "MCP Servers" }); - toolbar.createDiv({ cls: "af-toolbar-spacer" }); - - const addBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); - createIcon(addBtn, "plus", "af-btn-icon"); - addBtn.appendText(" Add Server"); - addBtn.onclick = () => this.navigate("add-mcp-server"); - - const servers = this.plugin.repository.getMcpServers(); - - if (servers.length === 0) { - this.renderEmptyState(page, "plug", "No MCP servers registered", "Click 'Add Server' above to register one."); - return; - } - - const grid = page.createDiv({ cls: "af-agents-grid" }); - for (const server of servers) { - this.renderMcpCard(grid, server); - } - } - - /** Transient per-server tool probe results (name → tools), populated by the - * on-demand "Probe tools" action. Not persisted. */ - private mcpProbeCache = new Map(); - - /** Whether the server has a stored auth token (OAuth/static bearer). */ - private mcpHasToken(server: McpServer): boolean { - return this.plugin.mcpAuth.hasToken(server.name); - } - - /** Whether an http/sse server still needs the user to authenticate (auth is - * oauth/bearer but no token is stored yet). */ - private mcpNeedsAuth(server: McpServer): boolean { - return ( - server.type !== "stdio" && - (server.auth === "oauth" || server.auth === "bearer") && - !this.mcpHasToken(server) - ); - } - - private renderMcpCard(container: HTMLElement, server: McpServer): void { - const card = container.createDiv({ cls: `af-mcp-card${server.enabled ? "" : " af-mcp-card-disabled"}` }); - const needsAuth = this.mcpNeedsAuth(server); - - const header = card.createDiv({ cls: "af-agent-card-header" }); - const statusClass = !server.enabled ? "disabled" : needsAuth ? "pending" : "idle"; - const avatarEl = header.createDiv({ cls: `af-agent-card-avatar ${statusClass}` }); - setIcon(avatarEl, "plug"); - - const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" }); - titleBlock.createDiv({ cls: "af-agent-card-name", text: server.name }); - - const metaRow = titleBlock.createDiv({ cls: "af-agent-card-desc af-mcp-meta" }); - metaRow.createSpan({ cls: "af-mcp-type-badge", text: server.type }); - if (server.source === "imported") { - metaRow.createSpan({ cls: "af-badge", text: "imported" }); - } - - // Enable/disable toggle — writes the registry file frontmatter. - const toggle = header.createDiv({ cls: `af-agent-card-toggle${server.enabled ? " on" : ""}` }); - toggle.onclick = (e) => { - e.stopPropagation(); - void this.plugin.repository.setMcpServerEnabled(server.name, !server.enabled).then(async () => { - await this.plugin.refreshFromVault(); - void this.render(); - }); - }; - - // Status badge - const statusBadge = card.createDiv({ cls: `af-mcp-status-badge ${!server.enabled ? "disabled" : needsAuth ? "needs-auth" : "connected"}` }); - const statusIcon = statusBadge.createSpan(); - if (!server.enabled) { - setIcon(statusIcon, "pause"); - statusBadge.createSpan({ text: " Disabled" }); - } else if (needsAuth) { - setIcon(statusIcon, "alert-circle"); - statusBadge.createSpan({ text: " Needs auth" }); - } else { - setIcon(statusIcon, "check-circle"); - statusBadge.createSpan({ text: server.type === "stdio" ? " Enabled" : " Authenticated" }); - } - - if (server.description) { - const desc = this.truncateDescription(server.description, 120); - card.createDiv({ cls: "af-mcp-description", text: desc }); - } - - const urlOrCmd = server.url ?? server.command ?? ""; - if (urlOrCmd) { - card.createDiv({ cls: "af-mcp-command", text: truncate(urlOrCmd, 60) }); - } - - // Tool count from the on-demand probe cache (if probed this session). - const probed = this.mcpProbeCache.get(server.name); - if (probed && probed.length > 0) { - const toolFooter = card.createDiv({ cls: "af-mcp-tool-footer" }); - const toolCount = toolFooter.createDiv({ cls: "af-mcp-tool-count" }); - const toolIcon = toolCount.createSpan(); - setIcon(toolIcon, "wrench"); - toolCount.createSpan({ text: ` ${probed.length} tools` }); - const chips = toolFooter.createDiv({ cls: "af-mcp-tool-chips" }); - for (const t of probed.slice(0, 4)) { - chips.createSpan({ cls: "af-mcp-tool-chip", text: t.name }); - } - if (probed.length > 4) { - chips.createSpan({ cls: "af-mcp-tool-chip af-mcp-tool-chip-more", text: `+${probed.length - 4}` }); - } - } - - // Auth / authenticating indicator for http/sse servers. - if (this.authenticatingServers.has(server.name)) { - const authRow = card.createDiv({ cls: "af-mcp-auth-row" }); - const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary", attr: { disabled: "true" } }); - const spinIcon = authBtn.createSpan({ cls: "af-spin" }); - setIcon(spinIcon, "loader-2"); - authBtn.appendText(" Authenticating…"); - } else if (server.enabled && needsAuth && server.auth === "oauth") { - const authRow = card.createDiv({ cls: "af-mcp-auth-row" }); - const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary" }); - const authIcon = authBtn.createSpan(); - setIcon(authIcon, "key"); - authBtn.appendText(" Authenticate"); - authBtn.onclick = (e) => { - e.stopPropagation(); - void this.authenticateMcpServer(server); - }; - } - - card.onclick = () => this.openMcpDetailSlideover(server); - } - - private async authenticateMcpServer(server: McpServer): Promise { - if (!server.url) { - new Notice("No URL found for this server — can't authenticate."); - return; - } - - this.authenticatingServers.add(server.name); - void this.render(); - - new Notice(`Authenticating ${server.name}… Complete authorization in your browser.`, 10000); - - try { - const transport = server.type === "sse" ? "sse" : "http"; - await this.plugin.mcpManager.authenticateServer(server.name, server.url, transport); - new Notice(`${server.name} authenticated successfully!`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Authentication failed: ${msg}`, 8000); - } finally { - this.authenticatingServers.delete(server.name); - void this.render(); - } - } - - /** On-demand tool probe for the MCP detail view. Stores results in the - * transient cache and re-renders. */ - private async probeMcpServer(server: McpServer): Promise { - try { - const tools = await this.plugin.mcpManager.probeServer(server); - this.mcpProbeCache.set(server.name, tools); - if (tools.length === 0) new Notice(`No tools discovered for ${server.name}.`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Probe failed: ${msg}`); - } - } - - private truncateDescription(text: string, maxLen: number): string { - // Take first sentence or first line, whichever is shorter - const firstLine = splitLines(text)[0] ?? text; - const firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine; - const candidate = firstSentence.length < firstLine.length ? firstSentence : firstLine; - if (candidate.length <= maxLen) return candidate; - return candidate.slice(0, maxLen - 1) + "…"; - } - - private openMcpDetailSlideover(server: McpServer): void { - this.contentEl.querySelector(".af-slideover-overlay")?.remove(); - - const overlay = this.contentEl.createDiv({ cls: "af-slideover-overlay" }); - const panel = overlay.createDiv({ cls: "af-slideover" }); - - const header = panel.createDiv({ cls: "af-slideover-header" }); - header.createDiv({ cls: "af-slideover-title", text: server.name }); - const closeBtn = header.createEl("button", { cls: "clickable-icon" }); - setIcon(closeBtn, "cross"); - closeBtn.onclick = () => overlay.remove(); - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - const body = panel.createDiv({ cls: "af-slideover-body" }); - - // Description - if (server.description) { - const descSection = body.createDiv({ cls: "af-slideover-section" }); - descSection.createDiv({ cls: "af-slideover-section-title", text: "DESCRIPTION" }); - descSection.createDiv({ cls: "af-mcp-detail-description", text: server.description }); - } - - // Server info - const infoSection = body.createDiv({ cls: "af-slideover-section" }); - infoSection.createDiv({ cls: "af-slideover-section-title", text: "SERVER INFO" }); - this.renderDetailRow(infoSection, "Name", server.name); - this.renderDetailRow(infoSection, "Transport", server.type); - this.renderDetailRow(infoSection, "Enabled", server.enabled ? "yes" : "no"); - if (server.type !== "stdio") { - this.renderDetailRow(infoSection, "Auth", server.auth ?? "none"); - this.renderDetailRow(infoSection, "Authenticated", this.mcpHasToken(server) ? "yes" : "no"); - } - if (server.source) this.renderDetailRow(infoSection, "Source", server.source); - if (server.url) this.renderDetailRow(infoSection, "URL", server.url); - if (server.command) this.renderDetailRow(infoSection, "Command", server.command); - if (server.args && server.args.length > 0) this.renderDetailRow(infoSection, "Args", server.args.join(" ")); - - // Tools — populated by the on-demand probe (registry definitions carry no - // probed tools until the user clicks "Probe tools"). - const probedTools = this.mcpProbeCache.get(server.name) ?? []; - const toolsSection = body.createDiv({ cls: "af-slideover-section" }); - const toolsTitleRow = toolsSection.createDiv({ cls: "af-slideover-section-title" }); - toolsTitleRow.setText(`TOOLS (${probedTools.length})`); - const probeBtn = toolsSection.createEl("button", { cls: "af-btn-sm" }); - const probeIcon = probeBtn.createSpan(); - setIcon(probeIcon, "wrench"); - probeBtn.appendText(" Probe tools"); - probeBtn.onclick = async () => { - probeBtn.disabled = true; - probeBtn.setText(" Probing…"); - await this.probeMcpServer(server); - overlay.remove(); - this.openMcpDetailSlideover(server); - }; - - if (probedTools.length > 0) { - for (const tool of probedTools) { - const toolItem = toolsSection.createDiv({ cls: "af-mcp-tool-detail" }); - const toolHeader = toolItem.createDiv({ cls: "af-mcp-tool-detail-header" }); - const toolNameEl = toolHeader.createSpan({ cls: "af-mcp-tool-detail-name" }); - const toolNameIcon = toolNameEl.createSpan(); - setIcon(toolNameIcon, "wrench"); - toolNameEl.createSpan({ text: ` ${tool.name}` }); - - if (tool.inputSchema) { - const params = (tool.inputSchema as { required?: string[] }).required ?? []; - if (params.length > 0) { - toolHeader.createSpan({ - cls: "af-mcp-tool-param-count", - text: `${params.length} param${params.length !== 1 ? "s" : ""}`, - }); - } - } - - if (tool.description) { - // Show first 2 lines of description, rest in collapsible - const descLines = splitLines(tool.description).filter((l) => l.trim()); - const shortDesc = descLines.slice(0, 2).join(" ").trim(); - const hasMore = descLines.length > 2; - - if (hasMore) { - const details = toolItem.createEl("details", { cls: "af-mcp-tool-detail-desc" }); - details.createEl("summary", { text: this.truncateDescription(shortDesc, 200) }); - details.createDiv({ cls: "af-mcp-tool-detail-full", text: tool.description }); - } else { - toolItem.createDiv({ cls: "af-mcp-tool-detail-desc", text: shortDesc }); - } - } - - // Input schema params - if (tool.inputSchema) { - const props = (tool.inputSchema as { properties?: Record }).properties; - const required = new Set((tool.inputSchema as { required?: string[] }).required ?? []); - if (props && Object.keys(props).length > 0) { - const paramsEl = toolItem.createDiv({ cls: "af-mcp-tool-params" }); - for (const [paramName, paramDef] of Object.entries(props)) { - const paramEl = paramsEl.createDiv({ cls: "af-mcp-tool-param" }); - paramEl.createSpan({ cls: "af-mcp-tool-param-name", text: paramName }); - if (paramDef.type) { - paramEl.createSpan({ cls: "af-mcp-tool-param-type", text: paramDef.type }); - } - if (required.has(paramName)) { - paramEl.createSpan({ cls: "af-mcp-tool-param-required", text: "required" }); - } - if (paramDef.description) { - paramEl.createSpan({ - cls: "af-mcp-tool-param-desc", - text: truncate(paramDef.description, 80), - }); - } - } - } - } - } - } else { - toolsSection.createDiv({ - cls: "af-form-hint", - text: "Click \"Probe tools\" to discover the tools this server exposes.", - }); - } - - // Actions section - const actionsSection = body.createDiv({ cls: "af-slideover-section" }); - actionsSection.createDiv({ cls: "af-slideover-section-title", text: "ACTIONS" }); - - if (server.enabled && server.url && server.auth === "oauth" && !this.mcpHasToken(server)) { - const authBtn = actionsSection.createEl("button", { cls: "af-btn-sm primary" }); - const authIcon = authBtn.createSpan(); - setIcon(authIcon, "key"); - authBtn.appendText(" Authenticate"); - authBtn.onclick = () => { - overlay.remove(); - void this.authenticateMcpServer(server); - }; - } - - const removeBtn = actionsSection.createEl("button", { cls: "af-btn-sm danger" }); - const removeIcon = removeBtn.createSpan(); - setIcon(removeIcon, "trash-2"); - removeBtn.appendText(" Remove Server"); - removeBtn.onclick = async () => { - try { - await this.plugin.repository.deleteMcpServer(server.name); - this.plugin.mcpAuth.removeToken(server.name); - this.mcpProbeCache.delete(server.name); - new Notice(`Server "${server.name}" removed.`); - overlay.remove(); - await this.plugin.refreshFromVault(); - void this.render(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to remove server: ${msg}`); - } - }; + renderSkillForm(page, this.skillFormDeps(), skill); } // ═══════════════════════════════════════════════════════ @@ -5973,237 +2094,7 @@ export class FleetDashboardView extends ItemView { private renderAddMcpServerPage(container: HTMLElement): void { const page = container.createDiv({ cls: "af-create-agent-page" }); - - // Header - const header = page.createDiv({ cls: "af-detail-header" }); - const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); - const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); - setIcon(avatar, "plus"); - const headerInfo = headerLeft.createDiv(); - headerInfo.createDiv({ cls: "af-detail-header-name", text: "Add MCP Server" }); - headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Register a new MCP server for agents to use" }); - const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); - - // Form state - const state: { - name: string; - transport: "stdio" | "http" | "sse"; - description: string; - command: string; - args: string; - envVars: string; - url: string; - headers: string; - auth: "none" | "bearer" | "oauth"; - bearerToken: string; - } = { - name: "", - transport: "stdio", - description: "", - command: "", - args: "", - envVars: "", - url: "", - headers: "", - auth: "none", - bearerToken: "", - }; - - const form = page.createDiv({ cls: "af-create-form" }); - - // ─── Server Details ─── - const detailsSection = form.createDiv({ cls: "af-create-section" }); - const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); - const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(detailsIcon, "plug"); - detailsHeader.createSpan({ text: "Server Details" }); - - this.createFormField(detailsSection, "Name", "my-server", "Unique name for this MCP server", (v) => { state.name = v; }); - - // Transport dropdown - const transportRow = detailsSection.createDiv({ cls: "af-form-row" }); - const transportLabel = transportRow.createDiv({ cls: "af-form-label" }); - transportLabel.setText("Transport"); - this.addTooltip(transportLabel, "stdio: local process, http/sse: remote server"); - const transportSelect = transportRow.createEl("select", { cls: "af-form-select" }); - transportSelect.createEl("option", { text: "stdio", attr: { value: "stdio" } }); - transportSelect.createEl("option", { text: "http", attr: { value: "http" } }); - transportSelect.createEl("option", { text: "sse", attr: { value: "sse" } }); - - this.createFormField(detailsSection, "Description", "What this server does (optional)", "Shown on the server card", (v) => { state.description = v; }); - - // ─── stdio fields ─── - const stdioSection = form.createDiv({ cls: "af-create-section" }); - const stdioHeader = stdioSection.createDiv({ cls: "af-create-section-header" }); - const stdioIcon = stdioHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(stdioIcon, "terminal"); - stdioHeader.createSpan({ text: "Process Configuration" }); - - this.createFormField(stdioSection, "Command", "npx @anthropic-ai/mcp-server-memory", "The command to run", (v) => { state.command = v; }); - this.createFormField(stdioSection, "Arguments", "--port 3000", "Space-separated arguments (optional)", (v) => { state.args = v; }); - - const envLabel = stdioSection.createDiv({ cls: "af-form-label" }); - envLabel.setText("Environment variables"); - this.addTooltip(envLabel, "One KEY=VALUE per line"); - const envTextarea = stdioSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "API_KEY=sk-...\nDEBUG=true", rows: "3" }, - }); - envTextarea.addEventListener("input", () => { state.envVars = envTextarea.value; }); - - // ─── http/sse fields ─── - const httpSection = form.createDiv({ cls: "af-create-section" }); - const httpHeader = httpSection.createDiv({ cls: "af-create-section-header" }); - const httpIcon = httpHeader.createSpan({ cls: "af-create-section-icon" }); - setIcon(httpIcon, "globe"); - httpHeader.createSpan({ text: "Remote Server Configuration" }); - - this.createFormField(httpSection, "URL", "https://mcp.example.com/sse", "Server endpoint URL", (v) => { state.url = v; }); - - const headersLabel = httpSection.createDiv({ cls: "af-form-label" }); - headersLabel.setText("Custom headers"); - this.addTooltip(headersLabel, "One Header: Value per line (optional, non-secret)"); - const headersTextarea = httpSection.createEl("textarea", { - cls: "af-create-prompt-textarea", - attr: { placeholder: "X-Custom-Header: value", rows: "2" }, - }); - headersTextarea.addEventListener("input", () => { state.headers = headersTextarea.value; }); - - // Auth dropdown - const authRow = httpSection.createDiv({ cls: "af-form-row" }); - const authLabel = authRow.createDiv({ cls: "af-form-label" }); - authLabel.setText("Authentication"); - this.addTooltip(authLabel, "none, a static bearer token, or OAuth (authenticate after saving)"); - const authSelect = authRow.createEl("select", { cls: "af-form-select" }); - authSelect.createEl("option", { text: "None", attr: { value: "none" } }); - authSelect.createEl("option", { text: "Bearer token", attr: { value: "bearer" } }); - authSelect.createEl("option", { text: "OAuth", attr: { value: "oauth" } }); - - // Bearer token field (shown only for auth=bearer). Stored in the keychain, - // never in the vault. - const bearerRow = httpSection.createDiv({ cls: "af-form-row" }); - const bearerLabel = bearerRow.createDiv({ cls: "af-form-label" }); - bearerLabel.setText("Bearer token"); - this.addTooltip(bearerLabel, "Stored securely in the OS keychain, never written to the vault"); - const bearerInput = bearerRow.createEl("input", { cls: "af-form-input", attr: { type: "password", placeholder: "sk-…" } }); - bearerInput.addEventListener("input", () => { state.bearerToken = bearerInput.value; }); - - const updateAuthVisibility = () => { - bearerRow.setCssStyles({ display: state.auth === "bearer" ? "" : "none" }); - }; - authSelect.addEventListener("change", () => { - state.auth = authSelect.value as "none" | "bearer" | "oauth"; - updateAuthVisibility(); - }); - updateAuthVisibility(); - - // Toggle section visibility based on transport - const updateTransportVisibility = () => { - stdioSection.setCssStyles({ display: state.transport === "stdio" ? "" : "none" }); - httpSection.setCssStyles({ display: state.transport !== "stdio" ? "" : "none" }); - }; - transportSelect.addEventListener("change", () => { - state.transport = transportSelect.value as "stdio" | "http" | "sse"; - updateTransportVisibility(); - }); - updateTransportVisibility(); - - // ─── Footer ─── - const footer = page.createDiv({ cls: "af-create-footer" }); - const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); - cancelBtn.onclick = () => this.navigate("mcp"); - - const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); - createIcon(submitBtn, "plus", "af-btn-icon"); - submitBtn.appendText(" Add Server"); - submitBtn.onclick = async () => { - const name = state.name.trim(); - if (!name) { new Notice("Server name is required."); return; } - - if (state.transport === "stdio") { - if (!state.command.trim()) { new Notice("Command is required for stdio servers."); return; } - } else { - if (!state.url.trim()) { new Notice("URL is required for HTTP/SSE servers."); return; } - } - - // Parse env vars - const envVars: Record = {}; - if (state.envVars.trim()) { - for (const line of splitLines(state.envVars)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx <= 0) { new Notice(`Invalid env var: ${trimmed}`); return; } - envVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); - } - } - - // Parse headers - const headers: Record = {}; - if (state.headers.trim()) { - for (const line of splitLines(state.headers)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const colonIdx = trimmed.indexOf(":"); - if (colonIdx <= 0) { new Notice(`Invalid header: ${trimmed}`); return; } - headers[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim(); - } - } - - // Parse args - const args = state.args.trim() ? state.args.trim().split(/\s+/) : undefined; - - if (this.plugin.repository.getMcpServerByName(name)) { - new Notice(`An MCP server named "${name}" already exists.`); - return; - } - if (state.transport !== "stdio" && state.auth === "bearer" && !state.bearerToken.trim()) { - new Notice("Enter a bearer token, or choose a different auth method."); - return; - } - - submitBtn.disabled = true; - submitBtn.setText("Adding..."); - - // Build the registry definition (non-secret fields only). The bearer - // token, if any, goes to the keychain — never into the vault file. - const server: McpServer = { - name, - type: state.transport, - enabled: true, - source: "manual", - status: "disconnected", - scope: "user", - tools: [], - toolDetails: [], - }; - if (state.transport === "stdio") { - server.command = state.command.trim(); - if (args) server.args = args; - if (Object.keys(envVars).length > 0) server.env = envVars; - } else { - server.url = state.url.trim(); - if (Object.keys(headers).length > 0) server.headers = headers; - server.auth = state.auth; - } - - try { - await this.plugin.repository.saveMcpServer(server, state.description.trim()); - if (state.transport !== "stdio" && state.auth === "bearer" && state.bearerToken.trim()) { - this.plugin.mcpAuth.storeStaticToken(name, state.bearerToken.trim()); - } - new Notice(`Server "${name}" added.`); - await this.plugin.refreshFromVault(); - this.navigate("mcp"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - new Notice(`Failed to add server: ${msg}`); - submitBtn.disabled = false; - submitBtn.setText(""); - createIcon(submitBtn, "plus", "af-btn-icon"); - submitBtn.appendText(" Add Server"); - } - }; + renderAddMcpServerForm(page, this.mcpFormDeps()); } private createFormField(container: HTMLElement, label: string, placeholder: string, desc: string, onChange: (v: string) => void, initialValue?: string): void { @@ -6229,28 +2120,6 @@ export class FleetDashboardView extends ItemView { } } -/** - * Map a channel status to the existing `af-agent-card-avatar` color class so that - * channel cards borrow the same visual language as agent cards (green=healthy, - * blue=transitioning, red=broken, gray=off). - */ -function channelStatusToAvatarClass(status: string): string { - switch (status) { - case "connected": - return "idle"; // green - case "connecting": - case "reconnecting": - return "pending"; // blue - case "needs-auth": - case "error": - return "error"; // red - case "stopped": - case "disabled": - default: - return "disabled"; // gray - } -} - /** * Format a token count for compact display: * 0 → "0" @@ -6300,21 +2169,3 @@ function cronToHuman(cron: string): string { } return cron; // fallback to raw cron if no match } - -/** Map a channel status to an `af-pill` color variant for the header badge. */ -function channelStatusPillColor(status: string): string { - switch (status) { - case "connected": - return "green"; - case "connecting": - case "reconnecting": - return "blue"; - case "needs-auth": - case "error": - return "red"; - case "stopped": - case "disabled": - default: - return ""; - } -} diff --git a/src/views/forms/agentForm.ts b/src/views/forms/agentForm.ts new file mode 100644 index 0000000..069b084 --- /dev/null +++ b/src/views/forms/agentForm.ts @@ -0,0 +1,1299 @@ +import { Notice, setIcon } from "obsidian"; +import { IconPickerModal } from "../../modals/iconPickerModal"; +import type AgentFleetPlugin from "../../main"; +import type { AgentConfig } from "../../types"; +import { slugify } from "../../utils/markdown"; +import { splitLines } from "../../utils/platform"; +import { createIcon } from "../../utils/icons"; +import { CODEX_MODEL_ALIASES, MODEL_ALIASES, renderModelPicker } from "../../components/modelPicker"; +import type { DashboardFormDeps } from "./shared"; +import { parseCronComponents } from "./shared"; + +// Adapter choices in the agent forms. "process"/"http" stay greyed out until +// those backends exist. +const ADAPTER_FORM_OPTIONS: Array<[string, string, boolean]> = [ + ["claude-code", "Claude Code", false], + ["codex", "Codex", false], + ["process", "Process (coming soon)", true], + ["http", "HTTP (coming soon)", true], +]; + +// Permission modes per adapter: [value, label, description]. +// Claude Code uses its native --permission-mode vocabulary; Codex maps onto +// sandbox levels (`codex exec` never prompts, so the sandbox is the only +// enforcement axis). +const CLAUDE_PERM_MODE_OPTIONS: Array<[string, string, string]> = [ + ["bypassPermissions", "Bypass Permissions", "Auto-approve everything except deny list"], + ["dontAsk", "Don’t Ask", "Auto-approve all tool calls"], + ["acceptEdits", "Accept Edits", "Auto-approve file edits, block bash unless allowed"], + ["plan", "Plan", "Read-only mode, no writes or commands"], + ["default", "Default", "Ask for each tool call"], +]; +const CODEX_PERM_MODE_OPTIONS: Array<[string, string, string]> = [ + ["bypassPermissions", "Bypass (no sandbox)", "No sandbox, auto-approve everything"], + ["workspace-write", "Workspace Write", "Sandboxed: writes only inside the working dir"], + ["read-only", "Read Only", "Sandboxed: no writes or side-effect commands"], +]; + +function isCodexAdapterValue(adapter: string): boolean { + const v = adapter.trim().toLowerCase(); + return v === "codex" || v === "openai-codex"; +} + +function permModeOptionsFor(adapter: string): Array<[string, string, string]> { + return isCodexAdapterValue(adapter) ? CODEX_PERM_MODE_OPTIONS : CLAUDE_PERM_MODE_OPTIONS; +} + +/** Translate a permission-mode value to the nearest equivalent when the user + * switches the form's adapter, so the dropdown always shows a valid choice. */ +function permModeForAdapter(value: string, adapter: string): string { + if (isCodexAdapterValue(adapter)) { + switch (value) { + case "acceptEdits": + case "default": + return "workspace-write"; + case "plan": + return "read-only"; + case "dontAsk": + return "bypassPermissions"; + default: + return CODEX_PERM_MODE_OPTIONS.some(([v]) => v === value) ? value : "bypassPermissions"; + } + } + switch (value) { + case "workspace-write": + return "acceptEdits"; + case "read-only": + return "plan"; + case "danger-full-access": + return "bypassPermissions"; + default: + return CLAUDE_PERM_MODE_OPTIONS.some(([v]) => v === value) ? value : "bypassPermissions"; + } +} + +/** One-line explanation of how permission modes map across adapter families. + * Shown under the permission-mode field after the user switches adapters, so + * the vocabulary swap (and the automatic remapping) isn't silent. */ +function adapterMappingHintText(adapter: string): string { + return isCodexAdapterValue(adapter) + ? "Codex enforces permissions via sandbox modes — your Claude mode was mapped: Accept Edits/Default ≈ Workspace Write, Plan ≈ Read Only, Don’t Ask ≈ Bypass." + : "Claude Code uses permission modes — your Codex sandbox was mapped: Workspace Write ≈ Accept Edits, Read Only ≈ Plan."; +} + +/** View helpers the agent forms borrow from the dashboard. */ +export interface AgentFormDeps extends DashboardFormDeps { + /** Navigate the dashboard to another page (post-save / cancel / delete). */ + navigate: (page: "agents" | "agent-detail" | "mcp", context?: string) => void; + /** Paint an agent avatar (emoji / lucide icon / initials) — shared with agent cards. */ + renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void; +} + +/** + * Simplified schedule picker for heartbeat — frequency intervals + time-of-day, + * no weekly/monthly/day-of-week selection. Outputs a cron expression into + * `state.heartbeatSchedule`. + */ +function renderHeartbeatSchedule( + container: HTMLElement, + state: { heartbeatSchedule: string }, +): void { + const parsed = parseCronComponents(state.heartbeatSchedule); + + // Frequency dropdown — intervals only, no weekly/monthly + const freqRow = container.createDiv({ cls: "af-form-row" }); + freqRow.createDiv({ cls: "af-form-label", text: "Frequency" }); + const freqSelect = freqRow.createEl("select", { cls: "af-form-select" }); + const freqOptions: Array<[string, string]> = [ + ["every_5m", "Every 5 minutes"], + ["every_15m", "Every 15 minutes"], + ["every_30m", "Every 30 minutes"], + ["every_hour", "Every hour"], + ["every_2h", "Every 2 hours"], + ["every_4h", "Every 4 hours"], + ["every_6h", "Every 6 hours"], + ["every_12h", "Every 12 hours"], + ["daily", "Once a day"], + ]; + // Map the current schedule to a freq key + let currentFreq = "every_hour"; + const cronToFreq: Record = { + "*/5 * * * *": "every_5m", + "*/15 * * * *": "every_15m", + "*/30 * * * *": "every_30m", + "0 * * * *": "every_hour", + "0 */2 * * *": "every_2h", + "0 */4 * * *": "every_4h", + "0 */6 * * *": "every_6h", + "0 */12 * * *": "every_12h", + }; + if (cronToFreq[state.heartbeatSchedule]) { + currentFreq = cronToFreq[state.heartbeatSchedule]!; + } else if (parsed.freq === "daily" || parsed.freq === "weekdays") { + currentFreq = "daily"; + } + + for (const [val, lbl] of freqOptions) { + const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === currentFreq) opt.selected = true; + } + + // Time row — only shown for "once a day" + const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" }); + timeRow.createDiv({ cls: "af-form-label", text: "Time" }); + const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" }); + + const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); + for (let h = 0; h < 24; h++) { + const ampm = h >= 12 ? "PM" : "AM"; + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; + const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } }); + if (h === parsed.hour) opt.selected = true; + } + + timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" }); + + const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); + for (let m = 0; m < 60; m += 5) { + const opt = minSelect.createEl("option", { + text: String(m).padStart(2, "0"), + attr: { value: String(m) }, + }); + if (m === parsed.minute) opt.selected = true; + } + + const showHide = () => { + timeRow.setCssStyles({ display: freqSelect.value === "daily" ? "" : "none" }); + }; + + const buildCron = () => { + const freq = freqSelect.value; + const h = hourSelect.value; + const m = minSelect.value; + switch (freq) { + case "every_5m": state.heartbeatSchedule = "*/5 * * * *"; break; + case "every_15m": state.heartbeatSchedule = "*/15 * * * *"; break; + case "every_30m": state.heartbeatSchedule = "*/30 * * * *"; break; + case "every_hour": state.heartbeatSchedule = "0 * * * *"; break; + case "every_2h": state.heartbeatSchedule = "0 */2 * * *"; break; + case "every_4h": state.heartbeatSchedule = "0 */4 * * *"; break; + case "every_6h": state.heartbeatSchedule = "0 */6 * * *"; break; + case "every_12h": state.heartbeatSchedule = "0 */12 * * *"; break; + case "daily": state.heartbeatSchedule = `${m} ${h} * * *`; break; + } + }; + + freqSelect.addEventListener("change", () => { showHide(); buildCron(); }); + hourSelect.addEventListener("change", buildCron); + minSelect.addEventListener("change", buildCron); + + showHide(); +} + +/** Render the per-agent MCP grant picker from the fleet registry. Shared by + * the create- and edit-agent forms. `selected` is the agent's + * `mcpServers` set, mutated in place as checkboxes toggle. */ +function renderAgentMcpPicker(section: HTMLElement, deps: AgentFormDeps, selected: Set): void { + section.createDiv({ + cls: "af-form-hint", + text: + "Servers from the MCP Servers tab. Checked servers are available to this " + + "agent on any adapter (Claude or Codex). Leave all unchecked to grant every enabled server.", + }); + const servers = deps.plugin.repository.getMcpServers(); + if (servers.length === 0) { + const hint = section.createDiv({ cls: "af-form-hint" }); + hint.appendText("No MCP servers registered yet. "); + const link = hint.createEl("a", { cls: "af-link", text: "Add one in the MCP Servers tab." }); + link.onclick = (e) => { e.preventDefault(); deps.navigate("mcp"); }; + return; + } + const grid = section.createDiv({ cls: "af-create-skills-grid" }); + for (const server of servers) { + const item = grid.createDiv({ cls: "af-mcp-agent-item" }); + const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } }); + cb.checked = selected.has(server.name); + cb.addEventListener("change", () => { + if (cb.checked) selected.add(server.name); + else selected.delete(server.name); + }); + const lbl = item.createDiv({ cls: "af-mcp-agent-label" }); + const nameRow = lbl.createDiv({ cls: "af-mcp-agent-name-row" }); + const dot = nameRow.createSpan({ cls: `af-mcp-status-dot ${server.enabled ? "idle" : "disabled"}` }); + dot.title = server.enabled ? "enabled" : "disabled"; + nameRow.createSpan({ cls: "af-create-skill-name", text: server.name }); + nameRow.createSpan({ cls: "af-mcp-agent-tool-count", text: server.type }); + if (!server.enabled) { + nameRow.createSpan({ cls: "af-mcp-agent-tool-count af-muted", text: "disabled" }); + } + } +} + +// ═══════════════════════════════════════════════════════ +// Create Agent Page +// ═══════════════════════════════════════════════════════ + +export function renderCreateAgentForm(page: HTMLElement, deps: AgentFormDeps): void { + const { plugin } = deps; + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(avatar, "plus"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Agent" }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new agent for your fleet" }); + + header.createDiv({ cls: "af-detail-header-actions" }); + // Form state + const state = { + name: "", + description: "", + avatar: "", + tags: "", + systemPrompt: "", + model: "default", + adapter: "claude-code", + cwd: "", + timeout: 300, + permissionMode: "bypassPermissions", + effort: "", + selectedSkills: new Set(), + selectedMcpServers: new Set(), + skillsBody: "", + contextBody: "", + approvalRequired: "", + memory: true, + enabled: true, + allowedCommands: "", + blockedCommands: "", + heartbeatEnabled: false, + heartbeatSchedule: "0 */6 * * *", + heartbeatBody: "", + heartbeatNotify: true, + heartbeatChannel: "", + heartbeatChannelTarget: "", + autoCompactThreshold: 85, + wikiReferences: [] as string[], + }; + + const TEMPLATES: Record = { + none: { label: "None", prompt: "" }, + coding: { label: "Coding Agent", prompt: "You are a coding agent. Review code, write tests, fix bugs, and implement features.\nFollow existing code conventions. Write clean, well-tested code.\nIf something is unclear, ask for clarification instead of guessing." }, + monitor: { label: "Monitor", prompt: "You are a monitoring agent. Check system status, alert on failures, and report on health metrics.\nBe concise and factual. Highlight anomalies clearly.\nInclude timestamps and relevant context in all reports." }, + briefing: { label: "Briefing", prompt: "You are a briefing agent. Summarize activity, generate reports, and surface key changes.\nPrioritize recent and important changes. Keep summaries concise.\nEnd with explicit next actions if they exist." }, + reviewer: { label: "Code Reviewer", prompt: "You are a code review agent. Analyze pull requests, suggest improvements, and flag potential issues.\nFocus on correctness, security, and maintainability.\nBe specific — reference file names and line numbers." }, + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Identity Section ─── + const identitySection = form.createDiv({ cls: "af-create-section" }); + const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); + const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(identityIcon, "user"); + identityHeader.createSpan({ text: "Identity" }); + + deps.createFormField(identitySection, "Name", "deploy-watcher", "Unique identifier (will be slugified)", (v) => { state.name = v; }); + deps.createFormField(identitySection, "Description", "Monitors deployments and reports status", "", (v) => { state.description = v; }); + + const avatarRow = identitySection.createDiv({ cls: "af-form-row" }); + avatarRow.createDiv({ cls: "af-form-label", text: "Avatar" }); + const avatarInput = avatarRow.createEl("input", { + cls: "af-form-input af-form-input-sm", + attr: { type: "text", placeholder: "🛡️" }, + }); + avatarInput.addEventListener("input", () => { state.avatar = avatarInput.value; }); + + deps.createFormField(identitySection, "Tags", "devops, monitoring", "Comma-separated", (v) => { state.tags = v; }); + + // Enabled toggle + const enabledRow = identitySection.createDiv({ cls: "af-form-row af-form-row-toggle" }); + enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" }); + enabledToggle.onclick = () => { + const isOn = enabledToggle.hasClass("on"); + enabledToggle.toggleClass("on", !isOn); + state.enabled = !isOn; + }; + + // ─── System Prompt Section ─── + const promptSection = form.createDiv({ cls: "af-create-section" }); + const promptHeader = promptSection.createDiv({ cls: "af-create-section-header" }); + const promptIcon = promptHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(promptIcon, "message-square"); + promptHeader.createSpan({ text: "System Prompt" }); + + const templateRow = promptSection.createDiv({ cls: "af-form-row" }); + templateRow.createDiv({ cls: "af-form-label", text: "Template" }); + const templateSelect = templateRow.createEl("select", { cls: "af-form-select" }); + for (const [key, { label }] of Object.entries(TEMPLATES)) { + templateSelect.createEl("option", { text: label, attr: { value: key } }); + } + + const promptTextarea = promptSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "You are a deployment monitoring agent...", rows: "10" }, + }); + promptTextarea.addEventListener("input", () => { state.systemPrompt = promptTextarea.value; }); + + templateSelect.addEventListener("change", () => { + const preset = TEMPLATES[templateSelect.value]; + if (preset && templateSelect.value !== "none") { + state.systemPrompt = preset.prompt; + promptTextarea.value = preset.prompt; + } + }); + + // ─── Runtime Config Section ─── + const configSection = form.createDiv({ cls: "af-create-section" }); + const configHeader = configSection.createDiv({ cls: "af-create-section-header" }); + const configIcon = configHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(configIcon, "settings"); + configHeader.createSpan({ text: "Runtime Config" }); + + const configGrid = configSection.createDiv({ cls: "af-create-config-grid" }); + + // Adapter (before model, so model dropdown updates when adapter changes) + const adapterRow = configGrid.createDiv({ cls: "af-form-row" }); + adapterRow.createDiv({ cls: "af-form-label", text: "Adapter" }); + const adapterSelect = adapterRow.createEl("select", { cls: "af-form-select" }); + for (const [val, lbl, disabled] of ADAPTER_FORM_OPTIONS) { + const opt = adapterSelect.createEl("option", { text: lbl, attr: { value: val, ...(disabled ? { disabled: "true" } : {}) } }); + if (val === "claude-code") opt.selected = true; + } + + // Model (dynamic based on adapter) + const modelRow = configGrid.createDiv({ cls: "af-form-row" }); + const modelLabel = modelRow.createDiv({ cls: "af-form-label", text: "Model" }); + deps.addTooltip( + modelLabel, + `Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom… for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${plugin.settings.defaultModel || "CLI default"}).`, + ); + const modelFieldWrap = modelRow.createDiv({ cls: "af-form-field-wrap" }); + const renderModelField = () => { + renderModelPicker(modelFieldWrap, { + value: state.model, + adapter: state.adapter, + onChange: (value) => { state.model = value; }, + }); + }; + renderModelField(); + + // The permission dropdown is built further down; it registers its + // repopulate hook here so adapter switches refresh it too. + let repopulatePermModeSelect: () => void = () => { /* assigned below */ }; + let showAdapterMappingHint: () => void = () => { /* assigned below */ }; + + adapterSelect.addEventListener("change", () => { + state.adapter = adapterSelect.value; + // A model alias from the other vendor would be passed verbatim and + // rejected by the CLI — reset to "use default" on family switch. + const otherAliases = isCodexAdapterValue(state.adapter) ? MODEL_ALIASES : CODEX_MODEL_ALIASES; + if (otherAliases.some((a) => a.value === state.model.trim())) { + state.model = ""; + } + renderModelField(); + state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); + repopulatePermModeSelect(); + showAdapterMappingHint(); + }); + + // Working Dir + const cwdRow = configGrid.createDiv({ cls: "af-form-row" }); + cwdRow.createDiv({ cls: "af-form-label", text: "Working Dir" }); + const cwdInput = cwdRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", placeholder: "Leave empty for vault root" }, + }); + cwdInput.addEventListener("input", () => { state.cwd = cwdInput.value; }); + + // Timeout + const timeoutRow = configGrid.createDiv({ cls: "af-form-row" }); + timeoutRow.createDiv({ cls: "af-form-label", text: "Timeout (sec)" }); + const timeoutInput = timeoutRow.createEl("input", { + cls: "af-form-input af-form-input-sm", + attr: { type: "number", value: "300" }, + }); + timeoutInput.addEventListener("input", () => { + const n = parseInt(timeoutInput.value, 10); + if (!isNaN(n) && n > 0) state.timeout = n; + }); + + // Permission Mode (options depend on the selected adapter) + const permRow = configGrid.createDiv({ cls: "af-form-row" }); + permRow.createDiv({ cls: "af-form-label", text: "Permission Mode" }); + const permSelect = permRow.createEl("select", { cls: "af-form-select" }); + const permDescEl = configGrid.createDiv({ cls: "af-form-hint", text: "" }); + repopulatePermModeSelect = () => { + // Snap any value left over from the other adapter family to its + // nearest equivalent so the dropdown always reflects what gets saved. + state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); + const options = permModeOptionsFor(state.adapter); + permSelect.empty(); + for (const [val, lbl] of options) { + const opt = permSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === state.permissionMode) opt.selected = true; + } + permDescEl.textContent = options.find(([v]) => v === permSelect.value)?.[2] ?? ""; + }; + repopulatePermModeSelect(); + // Adapter-switch mapping hint — hidden until the user actually changes the + // adapter, then explains how the previous mode family was translated. + const permMapHintEl = configGrid.createDiv({ cls: "af-form-hint af-adapter-map-hint" }); + permMapHintEl.setCssStyles({ display: "none" }); + showAdapterMappingHint = () => { + permMapHintEl.setText(adapterMappingHintText(state.adapter)); + permMapHintEl.setCssStyles({ display: "" }); + }; + permSelect.addEventListener("change", () => { + state.permissionMode = permSelect.value; + permDescEl.textContent = permModeOptionsFor(state.adapter).find(([v]) => v === permSelect.value)?.[2] ?? ""; + }); + + // Effort Level + const effortRow = configGrid.createDiv({ cls: "af-form-row" }); + effortRow.createDiv({ cls: "af-form-label", text: "Effort Level" }); + const effortSelect = effortRow.createEl("select", { cls: "af-form-select" }); + for (const [val, lbl] of [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) { + effortSelect.createEl("option", { text: lbl, attr: { value: val } }); + } + effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; }); + configGrid.createDiv({ cls: "af-form-hint", text: "Controls reasoning depth — low is fast, max is most thorough" }); + + // Auto-compact threshold + const compactRow = configGrid.createDiv({ cls: "af-form-row" }); + const compactLabel = compactRow.createDiv({ cls: "af-form-label", text: "Auto-compact at" }); + deps.addTooltip( + compactLabel, + "Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.", + ); + const compactInput = compactRow.createEl("input", { + cls: "af-form-input af-form-input-sm", + attr: { type: "number", min: "0", max: "100", value: String(state.autoCompactThreshold) }, + }); + compactInput.addEventListener("input", () => { + const n = parseInt(compactInput.value, 10); + if (!isNaN(n) && n >= 0 && n <= 100) state.autoCompactThreshold = n; + }); + configGrid.createDiv({ cls: "af-form-hint", text: "0 disables auto-compact" }); + + // Wiki references (consumer-mode read access to other keepers' scopes) + { + const wikiKeepers = plugin.runtime.getSnapshot().agents.filter((a) => a.wikiKeeper !== undefined); + if (wikiKeepers.length > 0) { + const wrRow = configGrid.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const wrLabel = wrRow.createDiv({ cls: "af-form-label", text: "Wiki access" }); + deps.addTooltip( + wrLabel, + "Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).", + ); + const wrWrap = wrRow.createDiv({ cls: "af-form-field-wrap" }); + for (const wk of wikiKeepers) { + const label = wrWrap.createEl("label", { cls: "af-form-checkbox-row" }); + const cb = label.createEl("input", { attr: { type: "checkbox" } }); + label.createSpan({ text: ` ${wk.name}`, cls: "af-form-checkbox-label" }); + cb.addEventListener("change", () => { + if (cb.checked) { + if (!state.wikiReferences.includes(wk.name)) state.wikiReferences.push(wk.name); + } else { + state.wikiReferences = state.wikiReferences.filter((n) => n !== wk.name); + } + }); + } + } + } + + // ─── Heartbeat Section ─── + { + const heartbeatSection = form.createDiv({ cls: "af-create-section" }); + const heartbeatHeader = heartbeatSection.createDiv({ cls: "af-create-section-header" }); + const heartbeatIcon = heartbeatHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(heartbeatIcon, "heart-pulse"); + const heartbeatHeaderLabel = heartbeatHeader.createSpan({ text: "Heartbeat" }); + deps.addTooltip(heartbeatHeaderLabel, "Autonomous periodic run — what the agent does when no one is asking"); + + const hbEnabledRow = heartbeatSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); + hbEnabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const hbEnabledToggle = hbEnabledRow.createDiv({ cls: "af-agent-card-toggle" }); + const hbBody = heartbeatSection.createDiv(); + hbBody.setCssStyles({ display: "none" }); + hbEnabledToggle.onclick = () => { + const isOn = hbEnabledToggle.hasClass("on"); + hbEnabledToggle.toggleClass("on", !isOn); + state.heartbeatEnabled = !isOn; + hbBody.setCssStyles({ display: !isOn ? "" : "none" }); + }; + + renderHeartbeatSchedule(hbBody, state); + + const hbNotifyRow = hbBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const hbNotifyLabel = hbNotifyRow.createDiv({ cls: "af-form-label" }); + hbNotifyLabel.setText("Notify"); + deps.addTooltip(hbNotifyLabel, "Show an Obsidian notice when the heartbeat completes"); + const hbNotifyToggle = hbNotifyRow.createDiv({ cls: "af-agent-card-toggle on" }); + hbNotifyToggle.onclick = () => { + const isOn = hbNotifyToggle.hasClass("on"); + hbNotifyToggle.toggleClass("on", !isOn); + state.heartbeatNotify = !isOn; + }; + + const createSnapshot = plugin.runtime.getSnapshot(); + const hbChannelRow = hbBody.createDiv({ cls: "af-form-row" }); + const hbChannelLabel = hbChannelRow.createDiv({ cls: "af-form-label" }); + hbChannelLabel.setText("Post to channel"); + deps.addTooltip(hbChannelLabel, "Heartbeat results are posted to this Slack channel when the run completes"); + const hbChannelSelect = hbChannelRow.createEl("select", { cls: "af-form-select" }); + hbChannelSelect.createEl("option", { text: "(none)", attr: { value: "" } }); + for (const ch of createSnapshot.channels) { + hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } }); + } + hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); }); + + const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" }); + const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" }); + hbTargetLabel.setText("Target ID"); + deps.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel’s first allowed user."); + const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } }); + hbTargetInput.value = state.heartbeatChannelTarget; + hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); }); + const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); }; + syncHbTarget(); + + const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" }); + hbInstructionLabel.setCssStyles({ width: "auto" }); + hbInstructionLabel.setCssStyles({ marginTop: "12px" }); + hbInstructionLabel.setText("Instruction"); + deps.addTooltip(hbInstructionLabel, "What the agent does on each heartbeat. Also used by the \"Run Now\" button."); + const hbTextarea = hbBody.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "Check status, scan for issues, report findings...", rows: "8" }, + }); + hbTextarea.addEventListener("input", () => { state.heartbeatBody = hbTextarea.value; }); + } + + // ─── Skills Section ─── + const skillsSection = form.createDiv({ cls: "af-create-section" }); + const skillsHeader = skillsSection.createDiv({ cls: "af-create-section-header" }); + const skillsIcon = skillsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(skillsIcon, "puzzle"); + skillsHeader.createSpan({ text: "Skills" }); + + const snapshot = plugin.runtime.getSnapshot(); + if (snapshot.skills.length > 0) { + skillsSection.createDiv({ cls: "af-form-sublabel", text: "Shared Skills" }); + const skillsGrid = skillsSection.createDiv({ cls: "af-create-skills-grid" }); + for (const skill of snapshot.skills) { + const item = skillsGrid.createDiv({ cls: "af-create-skill-item" }); + const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } }); + cb.addEventListener("change", () => { + if (cb.checked) state.selectedSkills.add(skill.name); + else state.selectedSkills.delete(skill.name); + }); + const lbl = item.createDiv({ cls: "af-create-skill-label" }); + lbl.createSpan({ cls: "af-create-skill-name", text: skill.name }); + if (skill.description) { + lbl.createSpan({ cls: "af-create-skill-desc", text: ` — ${skill.description}` }); + } + } + } + + const agentSkillsLabel = skillsSection.createDiv({ cls: "af-form-sublabel" }); + agentSkillsLabel.setText("Agent-specific skills"); + deps.addTooltip(agentSkillsLabel, "Custom skills/instructions only for this agent, not shared with others"); + const skillsTextarea = skillsSection.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Custom skills/instructions for this agent...", rows: "4" }, + }); + skillsTextarea.addEventListener("input", () => { state.skillsBody = skillsTextarea.value; }); + + // ─── MCP Servers Section ─── + { + const mcpSection = form.createDiv({ cls: "af-create-section" }); + const mcpHeader = mcpSection.createDiv({ cls: "af-create-section-header" }); + const mcpIcon = mcpHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(mcpIcon, "plug"); + const mcpHeaderLabel = mcpHeader.createSpan({ text: "MCP Servers" }); + deps.addTooltip(mcpHeaderLabel, "Grant agent access to MCP servers"); + renderAgentMcpPicker(mcpSection, deps, state.selectedMcpServers); + } + + // ─── Context Section ─── + const contextSection = form.createDiv({ cls: "af-create-section" }); + const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); + const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(contextIcon, "file-text"); + const contextHeaderLabel = contextHeader.createSpan({ text: "Context" }); + deps.addTooltip(contextHeaderLabel, "Project-specific context included in every run"); + const contextTextarea = contextSection.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Background info, repo structure, conventions...", rows: "4" }, + }); + contextTextarea.addEventListener("input", () => { state.contextBody = contextTextarea.value; }); + + // ─── Permissions Section ─── + const permsSection = form.createDiv({ cls: "af-create-section" }); + const permsHeader = permsSection.createDiv({ cls: "af-create-section-header" }); + const permsIcon = permsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(permsIcon, "shield-check"); + permsHeader.createSpan({ text: "Permissions" }); + + deps.createFormField(permsSection, "Approval required", "git_push, file_delete", "Comma-separated tool names", (v) => { state.approvalRequired = v; }); + + const allowRow = permsSection.createDiv({ cls: "af-form-row" }); + allowRow.createDiv({ cls: "af-form-label", text: "Allowed Commands" }); + const allowTextarea = allowRow.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Bash(curl *)\nBash(python3 *)\nRead\nEdit\nWrite", rows: "4" }, + }); + allowTextarea.addEventListener("input", () => { state.allowedCommands = allowTextarea.value; }); + + const denyRow = permsSection.createDiv({ cls: "af-form-row" }); + denyRow.createDiv({ cls: "af-form-label", text: "Blocked Commands" }); + const denyTextarea = denyRow.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Bash(git push *)\nBash(rm -rf *)\nBash(sudo *)", rows: "4" }, + }); + denyTextarea.addEventListener("input", () => { state.blockedCommands = denyTextarea.value; }); + + permsSection.createDiv({ + cls: "af-form-hint", + text: + "On Codex agents these become execpolicy command rules — only Bash(cmd args *) " + + "prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are " + + "ignored, and file/network access is governed by Permission Mode (the sandbox).", + }); + + const memoryRow = permsSection.createDiv({ cls: "af-form-row" }); + memoryRow.createDiv({ cls: "af-form-label", text: "Memory enabled" }); + const memoryToggle = memoryRow.createDiv({ cls: "af-agent-card-toggle on" }); + memoryToggle.onclick = () => { + const isOn = memoryToggle.hasClass("on"); + memoryToggle.toggleClass("on", !isOn); + state.memory = !isOn; + }; + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("agents"); + + const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(createBtn, "plus", "af-btn-icon"); + createBtn.appendText(" Create Agent"); + createBtn.onclick = async () => { + const name = state.name.trim(); + if (!name) { + new Notice("Agent name is required."); + return; + } + const slug = slugify(name); + if (plugin.repository.getAgentByName(slug)) { + new Notice(`Agent "${slug}" already exists.`); + return; + } + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + const restoreSubmit = deps.markSubmitBusy(createBtn, "Creating...", "plus", "Create Agent"); + try { + const parseLines = (s: string) => splitLines(s).map((l) => l.trim()).filter(Boolean); + await plugin.repository.createAgentFolder({ + name: slug, + description: state.description.trim(), + avatar: state.avatar.trim(), + tags: parseTags(state.tags), + systemPrompt: state.systemPrompt.trim(), + model: state.model.trim() || "default", + adapter: state.adapter, + cwd: state.cwd.trim(), + timeout: state.timeout, + permissionMode: state.permissionMode, + effort: state.effort || undefined, + approvalRequired: parseTags(state.approvalRequired), + memory: state.memory, + memoryMaxEntries: 100, + skills: Array.from(state.selectedSkills), + mcpServers: Array.from(state.selectedMcpServers), + skillsBody: state.skillsBody.trim(), + contextBody: state.contextBody.trim(), + enabled: state.enabled, + permissionRules: { + allow: parseLines(state.allowedCommands), + deny: parseLines(state.blockedCommands), + }, + autoCompactThreshold: state.autoCompactThreshold, + wikiReferences: state.wikiReferences, + }); + + // Save heartbeat if configured + if (state.heartbeatEnabled && state.heartbeatBody.trim()) { + await plugin.repository.updateHeartbeat(slug, { + enabled: state.heartbeatEnabled, + schedule: state.heartbeatSchedule.trim(), + notify: state.heartbeatNotify, + channel: state.heartbeatChannel, + channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "", + body: state.heartbeatBody.trim(), + }); + } + + new Notice(`Agent "${slug}" created.`); + await plugin.refreshFromVault(); + deps.navigate("agent-detail", slug); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create agent: ${msg}`); + restoreSubmit(); + } + }; +} + +// ═══════════════════════════════════════════════════════ +// Edit Agent Page +// ═══════════════════════════════════════════════════════ + +export function renderEditAgentForm(page: HTMLElement, deps: AgentFormDeps, agent: AgentConfig): void { + const { plugin } = deps; + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const avatarEl = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(avatarEl, "edit"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Agent: ${agent.name}` }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify agent configuration" }); + + header.createDiv({ cls: "af-detail-header-actions" }); + // Form state pre-filled + const state = { + name: agent.name, + description: agent.description ?? "", + avatar: agent.avatar, + tags: agent.tags.join(", "), + systemPrompt: agent.body, + model: agent.model, + adapter: agent.adapter, + cwd: agent.cwd ?? "", + timeout: agent.timeout, + permissionMode: agent.permissionMode, + effort: agent.effort ?? "", + selectedSkills: new Set(agent.skills), + selectedMcpServers: new Set(agent.mcpServers ?? []), + skillsBody: agent.skillsBody, + contextBody: agent.contextBody, + approvalRequired: agent.approvalRequired.join(", "), + memory: agent.memory, + memoryTokenBudget: agent.memoryTokenBudget, + reflectionEnabled: agent.reflection.enabled, + reflectionProposeSkills: agent.reflection.proposeSkills, + enabled: agent.enabled, + allowedCommands: agent.permissionRules.allow.join("\n"), + blockedCommands: agent.permissionRules.deny.join("\n"), + heartbeatEnabled: agent.heartbeatEnabled, + heartbeatSchedule: agent.heartbeatSchedule, + heartbeatBody: agent.heartbeatBody, + heartbeatNotify: agent.heartbeatNotify, + heartbeatChannel: agent.heartbeatChannel, + heartbeatChannelTarget: agent.heartbeatChannelTarget, + autoCompactThreshold: agent.autoCompactThreshold ?? 85, + wikiReferences: (agent.wikiReferences ?? []).map((r) => r.agent), + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Identity Section ─── + const identitySection = form.createDiv({ cls: "af-create-section" }); + const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); + const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(identityIcon, "user"); + identityHeader.createSpan({ text: "Identity" }); + + // Name (read-only for edit) + const nameRow = identitySection.createDiv({ cls: "af-form-row" }); + nameRow.createDiv({ cls: "af-form-label", text: "Name" }); + const nameInput = nameRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", value: agent.name, disabled: "true" }, + }); + nameInput.setCssStyles({ opacity: "0.6" }); + + deps.createFormField(identitySection, "Description", "Monitors deployments and reports status", "", (v) => { state.description = v; }, agent.description ?? ""); + + const avatarRow = identitySection.createDiv({ cls: "af-form-row" }); + avatarRow.createDiv({ cls: "af-form-label", text: "Avatar" }); + const avatarPickerBtn = avatarRow.createEl("button", { cls: "af-avatar-picker-btn" }); + const avatarPreview = avatarPickerBtn.createDiv({ cls: "af-avatar-picker-preview" }); + deps.renderAgentAvatar(avatarPreview, { ...agent, avatar: state.avatar ?? agent.avatar }); + avatarPickerBtn.createSpan({ cls: "af-avatar-picker-label", text: state.avatar || agent.avatar || "Pick icon…" }); + avatarPickerBtn.addEventListener("click", () => { + new IconPickerModal(plugin.app, state.avatar ?? agent.avatar, (iconName) => { + state.avatar = iconName; + avatarPreview.empty(); + setIcon(avatarPreview, iconName); + avatarPickerBtn.querySelector(".af-avatar-picker-label")?.setText(iconName); + }).open(); + }); + + deps.createFormField(identitySection, "Tags", "devops, monitoring", "Comma-separated", (v) => { state.tags = v; }, agent.tags.join(", ")); + + // ─── Enabled Toggle ─── + const enabledRow = identitySection.createDiv({ cls: "af-form-row" }); + enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` }); + enabledToggle.onclick = () => { + const isOn = enabledToggle.hasClass("on"); + enabledToggle.toggleClass("on", !isOn); + state.enabled = !isOn; + }; + + // ─── System Prompt Section ─── + const promptSection = form.createDiv({ cls: "af-create-section" }); + const promptHeader = promptSection.createDiv({ cls: "af-create-section-header" }); + const promptIcon = promptHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(promptIcon, "message-square"); + promptHeader.createSpan({ text: "System Prompt" }); + + const promptTextarea = promptSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "System prompt...", rows: "10" }, + }); + promptTextarea.value = agent.body; + promptTextarea.addEventListener("input", () => { state.systemPrompt = promptTextarea.value; }); + + // ─── Runtime Config Section ─── + const configSection = form.createDiv({ cls: "af-create-section" }); + const configHeader = configSection.createDiv({ cls: "af-create-section-header" }); + const configIcon = configHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(configIcon, "settings"); + configHeader.createSpan({ text: "Runtime Config" }); + + const configGrid = configSection.createDiv({ cls: "af-create-config-grid" }); + + // Adapter (before model) + const adapterRow = configGrid.createDiv({ cls: "af-form-row" }); + adapterRow.createDiv({ cls: "af-form-label", text: "Adapter" }); + const adapterSelect = adapterRow.createEl("select", { cls: "af-form-select" }); + for (const [val, lbl, disabled] of ADAPTER_FORM_OPTIONS) { + const opt = adapterSelect.createEl("option", { text: lbl, attr: { value: val, ...(disabled ? { disabled: "true" } : {}) } }); + if (val === agent.adapter || (isCodexAdapterValue(agent.adapter) && val === "codex")) opt.selected = true; + } + + // Model + const modelRow = configGrid.createDiv({ cls: "af-form-row" }); + const modelLabel = modelRow.createDiv({ cls: "af-form-label", text: "Model" }); + deps.addTooltip( + modelLabel, + `Aliases (opus/sonnet/haiku/opusplan) work on any backend. Choose Custom… for a pinned ID or Bedrock/Vertex/Foundry. Blank = use Settings default (${plugin.settings.defaultModel || "CLI default"}).`, + ); + const modelFieldWrap = modelRow.createDiv({ cls: "af-form-field-wrap" }); + const renderEditModelField = () => { + renderModelPicker(modelFieldWrap, { + value: state.model, + adapter: state.adapter, + onChange: (value) => { state.model = value; }, + }); + }; + renderEditModelField(); + + let repopulateEditPermModeSelect: () => void = () => { /* assigned below */ }; + let showEditAdapterMappingHint: () => void = () => { /* assigned below */ }; + + adapterSelect.addEventListener("change", () => { + state.adapter = adapterSelect.value; + const otherAliases = isCodexAdapterValue(state.adapter) ? MODEL_ALIASES : CODEX_MODEL_ALIASES; + if (otherAliases.some((a) => a.value === state.model.trim())) { + state.model = ""; + } + renderEditModelField(); + state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); + repopulateEditPermModeSelect(); + showEditAdapterMappingHint(); + }); + + // Working Dir + const cwdRow = configGrid.createDiv({ cls: "af-form-row" }); + cwdRow.createDiv({ cls: "af-form-label", text: "Working Dir" }); + const cwdInput = cwdRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", placeholder: "Leave empty for vault root", value: agent.cwd ?? "" }, + }); + cwdInput.addEventListener("input", () => { state.cwd = cwdInput.value; }); + + // Timeout + const timeoutRow = configGrid.createDiv({ cls: "af-form-row" }); + timeoutRow.createDiv({ cls: "af-form-label", text: "Timeout (sec)" }); + const timeoutInput = timeoutRow.createEl("input", { + cls: "af-form-input af-form-input-sm", + attr: { type: "number", value: String(agent.timeout) }, + }); + timeoutInput.addEventListener("input", () => { + const n = parseInt(timeoutInput.value, 10); + if (!isNaN(n) && n > 0) state.timeout = n; + }); + + // Permission Mode (options depend on the selected adapter) + const permRow = configGrid.createDiv({ cls: "af-form-row" }); + permRow.createDiv({ cls: "af-form-label", text: "Permission Mode" }); + const permSelect = permRow.createEl("select", { cls: "af-form-select" }); + const editPermDescEl = configGrid.createDiv({ cls: "af-form-hint", text: "" }); + repopulateEditPermModeSelect = () => { + // Snap any value left over from the other adapter family to its + // nearest equivalent so the dropdown always reflects what gets saved. + state.permissionMode = permModeForAdapter(state.permissionMode, state.adapter); + const options = permModeOptionsFor(state.adapter); + permSelect.empty(); + for (const [val, lbl] of options) { + const opt = permSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === state.permissionMode) opt.selected = true; + } + editPermDescEl.textContent = options.find(([v]) => v === permSelect.value)?.[2] ?? ""; + }; + repopulateEditPermModeSelect(); + // Adapter-switch mapping hint — hidden until the user actually changes the + // adapter, then explains how the previous mode family was translated. + const editPermMapHintEl = configGrid.createDiv({ cls: "af-form-hint af-adapter-map-hint" }); + editPermMapHintEl.setCssStyles({ display: "none" }); + showEditAdapterMappingHint = () => { + editPermMapHintEl.setText(adapterMappingHintText(state.adapter)); + editPermMapHintEl.setCssStyles({ display: "" }); + }; + permSelect.addEventListener("change", () => { + state.permissionMode = permSelect.value; + editPermDescEl.textContent = permModeOptionsFor(state.adapter).find(([v]) => v === permSelect.value)?.[2] ?? ""; + }); + + // Effort Level + const editEffortRow = configGrid.createDiv({ cls: "af-form-row" }); + editEffortRow.createDiv({ cls: "af-form-label", text: "Effort Level" }); + const editEffortSelect = editEffortRow.createEl("select", { cls: "af-form-select" }); + for (const [val, lbl] of [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) { + const opt = editEffortSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === (agent.effort ?? "")) opt.selected = true; + } + editEffortSelect.addEventListener("change", () => { state.effort = editEffortSelect.value; }); + configGrid.createDiv({ cls: "af-form-hint", text: "Controls reasoning depth — low is fast, max is most thorough" }); + + // Auto-compact threshold + const editCompactRow = configGrid.createDiv({ cls: "af-form-row" }); + const editCompactLabel = editCompactRow.createDiv({ cls: "af-form-label", text: "Auto-compact at" }); + deps.addTooltip( + editCompactLabel, + "Percent of context window at which the chat auto-invokes /compact before the next message. 85% is a good default. Set 0 to disable.", + ); + const editCompactInput = editCompactRow.createEl("input", { + cls: "af-form-input af-form-input-sm", + attr: { type: "number", min: "0", max: "100", value: String(state.autoCompactThreshold) }, + }); + editCompactInput.addEventListener("input", () => { + const n = parseInt(editCompactInput.value, 10); + if (!isNaN(n) && n >= 0 && n <= 100) state.autoCompactThreshold = n; + }); + configGrid.createDiv({ cls: "af-form-hint", text: "0 disables auto-compact" }); + + // Wiki references + { + const wikiKeepers = plugin.runtime.getSnapshot().agents.filter((a) => a.wikiKeeper !== undefined); + if (wikiKeepers.length > 0) { + const wrRow = configGrid.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const wrLabel = wrRow.createDiv({ cls: "af-form-label", text: "Wiki access" }); + deps.addTooltip( + wrLabel, + "Lets this agent read + cite from the selected Wiki Keeper scopes (requires the wiki-query skill).", + ); + const wrWrap = wrRow.createDiv({ cls: "af-form-field-wrap" }); + for (const wk of wikiKeepers) { + const label = wrWrap.createEl("label", { cls: "af-form-checkbox-row" }); + const cb = label.createEl("input", { attr: { type: "checkbox" } }); + if (state.wikiReferences.includes(wk.name)) cb.checked = true; + label.createSpan({ text: ` ${wk.name}`, cls: "af-form-checkbox-label" }); + cb.addEventListener("change", () => { + if (cb.checked) { + if (!state.wikiReferences.includes(wk.name)) state.wikiReferences.push(wk.name); + } else { + state.wikiReferences = state.wikiReferences.filter((n) => n !== wk.name); + } + }); + } + } + } + + // ─── Heartbeat Section ─── + if (agent.isFolder) { + const heartbeatSection = form.createDiv({ cls: "af-create-section" }); + const heartbeatHeader = heartbeatSection.createDiv({ cls: "af-create-section-header" }); + const heartbeatIcon = heartbeatHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(heartbeatIcon, "heart-pulse"); + const heartbeatHeaderLabel = heartbeatHeader.createSpan({ text: "Heartbeat" }); + deps.addTooltip(heartbeatHeaderLabel, "Autonomous periodic run — what the agent does when no one is asking"); + + const hbEnabledRow = heartbeatSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); + hbEnabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const hbEnabledToggle = hbEnabledRow.createDiv({ cls: `af-agent-card-toggle${state.heartbeatEnabled ? " on" : ""}` }); + const hbBody = heartbeatSection.createDiv(); + hbBody.setCssStyles({ display: state.heartbeatEnabled ? "" : "none" }); + hbEnabledToggle.onclick = () => { + const isOn = hbEnabledToggle.hasClass("on"); + hbEnabledToggle.toggleClass("on", !isOn); + state.heartbeatEnabled = !isOn; + hbBody.setCssStyles({ display: !isOn ? "" : "none" }); + }; + + renderHeartbeatSchedule(hbBody, state); + + const hbNotifyRow = hbBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const hbNotifyLabel = hbNotifyRow.createDiv({ cls: "af-form-label" }); + hbNotifyLabel.setText("Notify"); + deps.addTooltip(hbNotifyLabel, "Show an Obsidian notice when the heartbeat completes"); + const hbNotifyToggle = hbNotifyRow.createDiv({ cls: `af-agent-card-toggle${state.heartbeatNotify ? " on" : ""}` }); + hbNotifyToggle.onclick = () => { + const isOn = hbNotifyToggle.hasClass("on"); + hbNotifyToggle.toggleClass("on", !isOn); + state.heartbeatNotify = !isOn; + }; + + const editSnapshot = plugin.runtime.getSnapshot(); + const hbChannelRow = hbBody.createDiv({ cls: "af-form-row" }); + const hbChannelLabel = hbChannelRow.createDiv({ cls: "af-form-label" }); + hbChannelLabel.setText("Post to channel"); + deps.addTooltip(hbChannelLabel, "Heartbeat results are posted to this Slack channel when the run completes"); + const hbChannelSelect = hbChannelRow.createEl("select", { cls: "af-form-select" }); + hbChannelSelect.createEl("option", { text: "(none)", attr: { value: "" } }); + for (const ch of editSnapshot.channels) { + const opt = hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } }); + if (ch.name === state.heartbeatChannel) opt.selected = true; + } + hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); }); + + const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" }); + const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" }); + hbTargetLabel.setText("Target ID"); + deps.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel’s first allowed user."); + const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } }); + hbTargetInput.value = state.heartbeatChannelTarget; + hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); }); + const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); }; + syncHbTarget(); + + const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" }); + hbInstructionLabel.setCssStyles({ width: "auto" }); + hbInstructionLabel.setCssStyles({ marginTop: "12px" }); + hbInstructionLabel.setText("Instruction"); + deps.addTooltip(hbInstructionLabel, "What the agent does on each heartbeat. Also used by the \"Run Now\" button."); + const hbTextarea = hbBody.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "Check status, scan for issues, report findings...", rows: "8" }, + }); + hbTextarea.value = state.heartbeatBody; + hbTextarea.addEventListener("input", () => { state.heartbeatBody = hbTextarea.value; }); + } + + // ─── Skills Section ─── + const skillsSection = form.createDiv({ cls: "af-create-section" }); + const skillsHeader = skillsSection.createDiv({ cls: "af-create-section-header" }); + const skillsIcon = skillsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(skillsIcon, "puzzle"); + skillsHeader.createSpan({ text: "Skills" }); + + const snapshot = plugin.runtime.getSnapshot(); + if (snapshot.skills.length > 0) { + skillsSection.createDiv({ cls: "af-form-sublabel", text: "Shared Skills" }); + const skillsGrid = skillsSection.createDiv({ cls: "af-create-skills-grid" }); + for (const skill of snapshot.skills) { + const item = skillsGrid.createDiv({ cls: "af-create-skill-item" }); + const cb = item.createEl("input", { cls: "af-form-toggle", attr: { type: "checkbox" } }); + cb.checked = state.selectedSkills.has(skill.name); + cb.addEventListener("change", () => { + if (cb.checked) state.selectedSkills.add(skill.name); + else state.selectedSkills.delete(skill.name); + }); + const lbl = item.createDiv({ cls: "af-create-skill-label" }); + lbl.createSpan({ cls: "af-create-skill-name", text: skill.name }); + if (skill.description) { + lbl.createSpan({ cls: "af-create-skill-desc", text: ` — ${skill.description}` }); + } + } + } + + const agentSkillsLabel = skillsSection.createDiv({ cls: "af-form-sublabel" }); + agentSkillsLabel.setText("Agent-specific skills"); + deps.addTooltip(agentSkillsLabel, "Custom skills/instructions only for this agent, not shared with others"); + const skillsTextarea = skillsSection.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Custom skills/instructions for this agent...", rows: "4" }, + }); + skillsTextarea.value = agent.skillsBody; + skillsTextarea.addEventListener("input", () => { state.skillsBody = skillsTextarea.value; }); + + // ─── MCP Servers Section ─── + const mcpSection = form.createDiv({ cls: "af-create-section" }); + const mcpHeader = mcpSection.createDiv({ cls: "af-create-section-header" }); + const mcpIcon = mcpHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(mcpIcon, "plug"); + const editMcpHeaderLabel = mcpHeader.createSpan({ text: "MCP Servers" }); + deps.addTooltip(editMcpHeaderLabel, "Grant agent access to MCP servers"); + renderAgentMcpPicker(mcpSection, deps, state.selectedMcpServers); + + // ─── Context Section ─── + const contextSection = form.createDiv({ cls: "af-create-section" }); + const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); + const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(contextIcon, "file-text"); + const contextHeaderLabel = contextHeader.createSpan({ text: "Context" }); + deps.addTooltip(contextHeaderLabel, "Project-specific context included in every run"); + const contextTextarea = contextSection.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Background info, repo structure, conventions...", rows: "4" }, + }); + contextTextarea.value = agent.contextBody; + contextTextarea.addEventListener("input", () => { state.contextBody = contextTextarea.value; }); + + // ─── Permissions Section ─── + const permsSection = form.createDiv({ cls: "af-create-section" }); + const permsHeader = permsSection.createDiv({ cls: "af-create-section-header" }); + const permsIcon = permsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(permsIcon, "shield-check"); + permsHeader.createSpan({ text: "Permissions" }); + + deps.createFormField(permsSection, "Approval required", "git_push, file_delete", "Comma-separated tool names", (v) => { state.approvalRequired = v; }, agent.approvalRequired.join(", ")); + + const editAllowRow = permsSection.createDiv({ cls: "af-form-row" }); + editAllowRow.createDiv({ cls: "af-form-label", text: "Allowed Commands" }); + const editAllowTextarea = editAllowRow.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Bash(curl *)\nBash(python3 *)\nRead\nEdit\nWrite", rows: "4" }, + }); + editAllowTextarea.value = agent.permissionRules.allow.join("\n"); + editAllowTextarea.addEventListener("input", () => { state.allowedCommands = editAllowTextarea.value; }); + + const editDenyRow = permsSection.createDiv({ cls: "af-form-row" }); + editDenyRow.createDiv({ cls: "af-form-label", text: "Blocked Commands" }); + const editDenyTextarea = editDenyRow.createEl("textarea", { + cls: "af-create-textarea", + attr: { placeholder: "Bash(git push *)\nBash(rm -rf *)\nBash(sudo *)", rows: "4" }, + }); + editDenyTextarea.value = agent.permissionRules.deny.join("\n"); + editDenyTextarea.addEventListener("input", () => { state.blockedCommands = editDenyTextarea.value; }); + + permsSection.createDiv({ + cls: "af-form-hint", + text: + "On Codex agents these become execpolicy command rules — only Bash(cmd args *) " + + "prefixes are enforced; tool-name rules (Read/Write) and mid-pattern wildcards are " + + "ignored, and file/network access is governed by Permission Mode (the sandbox).", + }); + + const memoryRow = permsSection.createDiv({ cls: "af-form-row" }); + memoryRow.createDiv({ cls: "af-form-label", text: "Memory enabled" }); + const memoryToggle = memoryRow.createDiv({ cls: `af-agent-card-toggle${agent.memory ? " on" : ""}` }); + memoryToggle.onclick = () => { + const isOn = memoryToggle.hasClass("on"); + memoryToggle.toggleClass("on", !isOn); + state.memory = !isOn; + }; + + const budgetRow = permsSection.createDiv({ cls: "af-form-row" }); + budgetRow.createDiv({ cls: "af-form-label", text: "Memory token budget" }); + const budgetInput = budgetRow.createEl("input", { + cls: "af-create-input", + attr: { type: "number", min: "200", step: "100" }, + }); + budgetInput.value = String(state.memoryTokenBudget); + budgetInput.addEventListener("input", () => { + const v = parseInt(budgetInput.value, 10); + if (Number.isFinite(v)) state.memoryTokenBudget = v; + }); + + const reflectRow = permsSection.createDiv({ cls: "af-form-row" }); + reflectRow.createDiv({ cls: "af-form-label", text: "Nightly reflection" }); + const reflectToggle = reflectRow.createDiv({ + cls: `af-agent-card-toggle${agent.reflection.enabled ? " on" : ""}`, + }); + reflectToggle.onclick = () => { + const isOn = reflectToggle.hasClass("on"); + reflectToggle.toggleClass("on", !isOn); + state.reflectionEnabled = !isOn; + }; + + const proposeRow = permsSection.createDiv({ cls: "af-form-row" }); + proposeRow.createDiv({ cls: "af-form-label", text: "Propose skills from memory" }); + const proposeToggle = proposeRow.createDiv({ + cls: `af-agent-card-toggle${agent.reflection.proposeSkills ? " on" : ""}`, + }); + proposeToggle.onclick = () => { + const isOn = proposeToggle.hasClass("on"); + proposeToggle.toggleClass("on", !isOn); + state.reflectionProposeSkills = !isOn; + }; + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + + const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); + createIcon(deleteBtn, "trash-2", "af-btn-icon"); + deleteBtn.appendText(" Delete"); + deleteBtn.onclick = () => void plugin.deleteAgent(agent.name); + + footer.createDiv({ cls: "af-toolbar-spacer" }); + + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("agent-detail", agent.name); + + const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(saveBtn, "check", "af-btn-icon"); + saveBtn.appendText(" Save Changes"); + saveBtn.onclick = async () => { + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + const restoreSubmit = deps.markSubmitBusy(saveBtn, "Saving...", "check", "Save Changes"); + try { + const parseLines = (s: string) => splitLines(s).map((l) => l.trim()).filter(Boolean); + await plugin.repository.updateAgent(agent.name, { + description: state.description.trim(), + avatar: state.avatar.trim(), + tags: parseTags(state.tags), + systemPrompt: state.systemPrompt.trim(), + model: state.model.trim() || "default", + adapter: state.adapter, + cwd: state.cwd.trim(), + timeout: state.timeout, + permissionMode: state.permissionMode, + effort: state.effort || undefined, + approvalRequired: parseTags(state.approvalRequired), + memory: state.memory, + memoryTokenBudget: state.memoryTokenBudget, + reflectionEnabled: state.reflectionEnabled, + reflectionProposeSkills: state.reflectionProposeSkills, + skills: Array.from(state.selectedSkills), + mcpServers: Array.from(state.selectedMcpServers), + skillsBody: state.skillsBody.trim(), + contextBody: state.contextBody.trim(), + enabled: state.enabled, + permissionRules: { + allow: parseLines(state.allowedCommands), + deny: parseLines(state.blockedCommands), + }, + autoCompactThreshold: state.autoCompactThreshold, + wikiReferences: state.wikiReferences, + }); + // Persist heartbeat separately (lives in HEARTBEAT.md, not agent.md/config.md) + if (agent.isFolder) { + await plugin.repository.updateHeartbeat(agent.name, { + enabled: state.heartbeatEnabled, + schedule: state.heartbeatSchedule.trim(), + notify: state.heartbeatNotify, + channel: state.heartbeatChannel, + channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "", + body: state.heartbeatBody.trim(), + }); + } + + new Notice(`Agent "${agent.name}" updated.`); + await plugin.refreshFromVault(); + deps.navigate("agent-detail", agent.name); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to update agent: ${msg}`); + restoreSubmit(); + } + }; +} diff --git a/src/views/forms/channelForm.ts b/src/views/forms/channelForm.ts new file mode 100644 index 0000000..7a6a21a --- /dev/null +++ b/src/views/forms/channelForm.ts @@ -0,0 +1,420 @@ +import { Notice, setIcon } from "obsidian"; +import { ConfirmModal } from "../../modals/confirmModal"; +import type AgentFleetPlugin from "../../main"; +import type { ChannelConfig } from "../../types"; +import { slugify, stringifyMarkdownWithFrontmatter } from "../../utils/markdown"; +import { createIcon } from "../../utils/icons"; + +/** + * Map a channel status to the existing `af-agent-card-avatar` color class so that + * channel cards borrow the same visual language as agent cards (green=healthy, + * blue=transitioning, red=broken, gray=off). + */ +export function channelStatusToAvatarClass(status: string): string { + switch (status) { + case "connected": + return "idle"; // green + case "connecting": + case "reconnecting": + return "pending"; // blue + case "needs-auth": + case "error": + return "error"; // red + case "stopped": + case "disabled": + default: + return "disabled"; // gray + } +} + +/** View helpers the channel form borrows from the dashboard so the extracted + * markup stays byte-identical to what the view used to render inline. */ +export interface ChannelFormDeps { + plugin: AgentFleetPlugin; + /** Navigate the dashboard to another page (post-save / cancel / delete). */ + navigate: (page: "channels" | "edit-channel", context?: string) => void; + /** Append a small info icon with a hover tooltip to a label element. */ + addTooltip: (labelEl: HTMLElement, text: string) => void; + /** Standard labelled text-input form row. */ + createFormField: ( + container: HTMLElement, + label: string, + placeholder: string, + desc: string, + onChange: (v: string) => void, + initialValue?: string, + ) => void; + /** Disable a submit button while an async save is in flight; returns a restore fn. */ + markSubmitBusy: ( + btn: HTMLButtonElement, + busyLabel: string, + iconName: string, + label: string, + ) => () => void; +} + +/** + * Shared channel form used by both the Create Channel and Edit Channel pages. + * Pass `channel` to render in edit mode (pre-filled fields, read-only name, + * delete button, "Save Changes" submit); omit it for create mode. + */ +export function renderChannelForm( + page: HTMLElement, + deps: ChannelFormDeps, + channel?: ChannelConfig, +): void { + const { plugin } = deps; + const snapshot = plugin.runtime.getSnapshot(); + const credentials = plugin.channelCredentials.list(); + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + if (channel) { + const status = plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled"; + const avatarEl = headerLeft.createDiv({ cls: `af-agent-card-avatar ${channelStatusToAvatarClass(status)}` }); + setIcon(avatarEl, "radio"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Channel: ${channel.name}` }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: `Status: ${status}` }); + } else { + const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(avatar, "plus"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Channel" }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Connect an external chat transport to an agent" }); + } + header.createDiv({ cls: "af-detail-header-actions" }); + + // Form state (pre-filled in edit mode) + const state = { + name: "", + type: (channel ? channel.type : "slack") as ChannelConfig["type"], + defaultAgent: channel ? channel.defaultAgent : (snapshot.agents[0]?.name ?? ""), + allowedAgents: channel ? [...channel.allowedAgents] : ([] as string[]), + credentialRef: channel ? channel.credentialRef : (credentials[0]?.ref ?? ""), + allowedUsers: channel ? channel.allowedUsers.join("\n") : "", + perUserSessions: channel ? channel.perUserSessions : true, + channelContext: channel ? channel.channelContext : "", + enabled: channel ? channel.enabled : true, + tags: channel ? channel.tags.join(", ") : "", + body: channel ? channel.body : "", + transportJson: channel && Object.keys(channel.transport).length > 0 + ? JSON.stringify(channel.transport, null, 2) + : "", + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Channel Details ─── + const detailsSection = form.createDiv({ cls: "af-create-section" }); + const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); + const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(detailsIcon, "radio"); + detailsHeader.createSpan({ text: "Channel Details" }); + + if (channel) { + // Name (read-only) + const nameRow = detailsSection.createDiv({ cls: "af-form-row" }); + nameRow.createDiv({ cls: "af-form-label", text: "Name" }); + const nameInput = nameRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", value: channel.name, disabled: "true" }, + }); + nameInput.setCssStyles({ opacity: "0.6" }); + } else { + deps.createFormField(detailsSection, "Name", "my-slack", "Unique identifier for this channel", (v) => { state.name = v; }); + } + + // Type dropdown — only Slack is supported in v1; others shown as disabled + const typeRow = detailsSection.createDiv({ cls: "af-form-row" }); + typeRow.createDiv({ cls: "af-form-label", text: "Type" }); + const typeSelect = typeRow.createEl("select", { cls: "af-form-select" }); + for (const t of ["slack", "telegram", "discord"] as const) { + const opt = typeSelect.createEl("option", { text: t, attr: { value: t } }); + if (channel && t === channel.type) opt.selected = true; + } + typeSelect.addEventListener("change", () => { state.type = typeSelect.value as ChannelConfig["type"]; }); + + // Credential dropdown + const credRow = detailsSection.createDiv({ cls: "af-form-row" }); + const credLabel = credRow.createDiv({ cls: "af-form-label" }); + credLabel.setText("Credential"); + deps.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials"); + const credSelect = credRow.createEl("select", { cls: "af-form-select" }); + if (credentials.length === 0) { + credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } }); + } + for (const c of credentials) { + const opt = credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } }); + if (channel && c.ref === channel.credentialRef) opt.selected = true; + } + credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; }); + + // Enabled toggle + const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); + enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${state.enabled ? " on" : ""}` }); + enabledToggle.onclick = () => { + const isOn = enabledToggle.hasClass("on"); + enabledToggle.toggleClass("on", !isOn); + state.enabled = !isOn; + }; + + // ─── Agent Routing ─── + const agentSection = form.createDiv({ cls: "af-create-section" }); + const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" }); + const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(agentIcon, "bot"); + agentHeader.createSpan({ text: "Agent Routing" }); + + // Default agent dropdown + const defaultRow = agentSection.createDiv({ cls: "af-form-row" }); + const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" }); + defaultLabel.setText("Default agent"); + deps.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message"); + const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" }); + for (const a of snapshot.agents) { + const opt = defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } }); + if (channel && a.name === channel.defaultAgent) opt.selected = true; + } + defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; }); + + // Allowed agents checkboxes + const allowedRow = agentSection.createDiv({ cls: "af-form-row" }); + const allowedLabel = allowedRow.createDiv({ cls: "af-form-label" }); + allowedLabel.setText("Allowed agents"); + deps.addTooltip(allowedLabel, "Agents reachable via @prefix. Leave unchecked to allow all agents."); + const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" }); + for (const a of snapshot.agents) { + const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" }); + const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } }); + if (channel && channel.allowedAgents.includes(a.name)) cb.checked = true; + label.appendText(` ${a.name}`); + cb.addEventListener("change", () => { + if (cb.checked) { + if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name); + } else { + state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name); + } + }); + } + + // Per-user sessions toggle + const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" }); + perUserLabel.setText("Per-user sessions"); + deps.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session"); + const perUserToggle = perUserRow.createDiv({ cls: `af-agent-card-toggle${state.perUserSessions ? " on" : ""}` }); + perUserToggle.onclick = () => { + const isOn = perUserToggle.hasClass("on"); + perUserToggle.toggleClass("on", !isOn); + state.perUserSessions = !isOn; + }; + + // ─── Access Control ─── + const accessSection = form.createDiv({ cls: "af-create-section" }); + const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" }); + const accessIcon = accessHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(accessIcon, "shield-check"); + accessHeader.createSpan({ text: "Access Control" }); + + const usersLabel = accessSection.createDiv({ cls: "af-form-label" }); + usersLabel.setText("Allowed users"); + deps.addTooltip( + usersLabel, + channel + ? "Slack user IDs (U...), one per line. Only listed users can reach the bot." + : "User IDs, one per line — Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot.", + ); + const usersTextarea = accessSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" }, + }); + if (channel) usersTextarea.value = channel.allowedUsers.join("\n"); + usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; }); + + // ─── Channel Context ─── + const contextSection = form.createDiv({ cls: "af-create-section" }); + const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" }); + const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(contextIcon, "message-square"); + const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" }); + deps.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel"); + const contextTextarea = contextSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" }, + }); + if (channel) contextTextarea.value = channel.channelContext; + contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; }); + + deps.createFormField( + detailsSection, "Tags", "ops, internal", "Comma-separated metadata", + (v) => { state.tags = v; }, + channel ? channel.tags.join(", ") : undefined, + ); + + // ─── Advanced ─── + const advSection = form.createDiv({ cls: "af-create-section" }); + const advHeader = advSection.createDiv({ cls: "af-create-section-header" }); + const advIcon = advHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(advIcon, "settings"); + const advHeaderLabel = advHeader.createSpan({ text: "Advanced" }); + deps.addTooltip(advHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides"); + + const bodyLabel = advSection.createDiv({ cls: "af-form-label" }); + bodyLabel.setText("Body (markdown)"); + if (!channel) { + deps.addTooltip(bodyLabel, "Free-form notes for this channel. Shown in the channel detail page; not sent to the agent."); + } + const bodyTextarea = advSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" }, + }); + if (channel) bodyTextarea.value = channel.body; + bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; }); + + const transportLabel = advSection.createDiv({ cls: "af-form-label" }); + transportLabel.setText("Transport config (JSON)"); + deps.addTooltip( + transportLabel, + channel + ? "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults." + : "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults.", + ); + const transportTextarea = advSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" }, + }); + if (channel) transportTextarea.value = state.transportJson; + transportTextarea.addEventListener("input", () => { state.transportJson = transportTextarea.value; }); + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + + if (channel) { + const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); + createIcon(deleteBtn, "trash-2", "af-btn-icon"); + deleteBtn.appendText(" Delete"); + deleteBtn.onclick = () => { + new ConfirmModal(plugin.app, { + title: `Delete channel "${channel.name}"?`, + body: "The channel file will be moved to your system trash and can be recovered.", + confirmText: "Delete", + danger: true, + onConfirm: async () => { + await plugin.repository.deleteChannel(channel.name); + new Notice(`Channel "${channel.name}" deleted.`); + await new Promise((r) => window.setTimeout(r, 200)); + await plugin.refreshFromVault(); + deps.navigate("channels"); + }, + }).open(); + }; + + footer.createDiv({ cls: "af-toolbar-spacer" }); + } + + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("channels"); + + const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(submitBtn, channel ? "check" : "plus", "af-btn-icon"); + submitBtn.appendText(channel ? " Save Changes" : " Create Channel"); + + const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean); + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + + /** Parse the transport JSON textarea; returns `null` (after a Notice) when invalid. */ + const parseTransport = (): Record | undefined | null => { + if (!state.transportJson.trim()) return undefined; + try { + const parsed: unknown = JSON.parse(state.transportJson); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + new Notice("Transport config must be a JSON object."); + return null; + } catch (err) { + new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + }; + + if (channel) { + // Edit mode: update the existing channel file in place. + submitBtn.onclick = async () => { + let transport = parseTransport(); + if (transport === null) return; + if (transport === undefined) transport = {}; + + const restoreSubmit = deps.markSubmitBusy(submitBtn, "Saving...", "check", "Save Changes"); + try { + await plugin.repository.updateChannel(channel.name, { + type: state.type, + default_agent: state.defaultAgent, + allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : [], + enabled: state.enabled, + credential_ref: state.credentialRef, + allowed_users: parseUsers(state.allowedUsers), + per_user_sessions: state.perUserSessions, + channel_context: state.channelContext.trim(), + tags: parseTags(state.tags), + body: state.body.trim(), + transport, + }); + new Notice(`Channel "${channel.name}" updated.`); + await plugin.refreshFromVault(); + deps.navigate("channels"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to update channel: ${msg}`); + restoreSubmit(); + } + }; + } else { + // Create mode: write a new channel file. + submitBtn.onclick = async () => { + const name = state.name.trim(); + if (!name) { new Notice("Channel name is required."); return; } + if (!state.credentialRef) { new Notice("Select a credential."); return; } + + const transport = parseTransport(); + if (transport === null) return; + + const frontmatter: Record = { + name: slugify(name), + type: state.type, + default_agent: state.defaultAgent, + allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : undefined, + enabled: state.enabled, + credential_ref: state.credentialRef, + allowed_users: parseUsers(state.allowedUsers), + per_user_sessions: state.perUserSessions, + channel_context: state.channelContext.trim() || undefined, + tags: parseTags(state.tags).length > 0 ? parseTags(state.tags) : undefined, + transport, + }; + + const restoreSubmit = deps.markSubmitBusy(submitBtn, "Creating...", "plus", "Create Channel"); + try { + const channelSlug = slugify(name); + const path = await plugin.repository.getAvailablePath( + plugin.repository.getSubfolder("channels"), + channelSlug, + ); + await plugin.app.vault.create( + path, + stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim()), + ); + new Notice(`Channel "${channelSlug}" created.`); + await plugin.refreshFromVault(); + deps.navigate("edit-channel", channelSlug); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create channel: ${msg}`); + restoreSubmit(); + } + }; + } +} diff --git a/src/views/forms/mcpForm.ts b/src/views/forms/mcpForm.ts new file mode 100644 index 0000000..7cd0d46 --- /dev/null +++ b/src/views/forms/mcpForm.ts @@ -0,0 +1,251 @@ +import { Notice, setIcon } from "obsidian"; +import type { McpServer } from "../../types"; +import { splitLines } from "../../utils/platform"; +import { createIcon } from "../../utils/icons"; +import type { DashboardFormDeps } from "./shared"; + +/** View helpers the MCP server form borrows from the dashboard. */ +export interface McpFormDeps extends DashboardFormDeps { + /** Navigate the dashboard to another page (post-save / cancel). */ + navigate: (page: "mcp") => void; +} + +/** + * The "Add MCP Server" registration form: stdio (local process) or http/sse + * (remote) transports, with optional bearer-token/OAuth auth. Secrets go to + * the OS keychain via `plugin.mcpAuth`, never into the vault file. + */ +export function renderAddMcpServerForm(page: HTMLElement, deps: McpFormDeps): void { + const { plugin } = deps; + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(avatar, "plus"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: "Add MCP Server" }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Register a new MCP server for agents to use" }); + header.createDiv({ cls: "af-detail-header-actions" }); + + // Form state + const state: { + name: string; + transport: "stdio" | "http" | "sse"; + description: string; + command: string; + args: string; + envVars: string; + url: string; + headers: string; + auth: "none" | "bearer" | "oauth"; + bearerToken: string; + } = { + name: "", + transport: "stdio", + description: "", + command: "", + args: "", + envVars: "", + url: "", + headers: "", + auth: "none", + bearerToken: "", + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Server Details ─── + const detailsSection = form.createDiv({ cls: "af-create-section" }); + const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); + const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(detailsIcon, "plug"); + detailsHeader.createSpan({ text: "Server Details" }); + + deps.createFormField(detailsSection, "Name", "my-server", "Unique name for this MCP server", (v) => { state.name = v; }); + + // Transport dropdown + const transportRow = detailsSection.createDiv({ cls: "af-form-row" }); + const transportLabel = transportRow.createDiv({ cls: "af-form-label" }); + transportLabel.setText("Transport"); + deps.addTooltip(transportLabel, "stdio: local process, http/sse: remote server"); + const transportSelect = transportRow.createEl("select", { cls: "af-form-select" }); + transportSelect.createEl("option", { text: "stdio", attr: { value: "stdio" } }); + transportSelect.createEl("option", { text: "http", attr: { value: "http" } }); + transportSelect.createEl("option", { text: "sse", attr: { value: "sse" } }); + + deps.createFormField(detailsSection, "Description", "What this server does (optional)", "Shown on the server card", (v) => { state.description = v; }); + + // ─── stdio fields ─── + const stdioSection = form.createDiv({ cls: "af-create-section" }); + const stdioHeader = stdioSection.createDiv({ cls: "af-create-section-header" }); + const stdioIcon = stdioHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(stdioIcon, "terminal"); + stdioHeader.createSpan({ text: "Process Configuration" }); + + deps.createFormField(stdioSection, "Command", "npx @anthropic-ai/mcp-server-memory", "The command to run", (v) => { state.command = v; }); + deps.createFormField(stdioSection, "Arguments", "--port 3000", "Space-separated arguments (optional)", (v) => { state.args = v; }); + + const envLabel = stdioSection.createDiv({ cls: "af-form-label" }); + envLabel.setText("Environment variables"); + deps.addTooltip(envLabel, "One KEY=VALUE per line"); + const envTextarea = stdioSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "API_KEY=sk-...\nDEBUG=true", rows: "3" }, + }); + envTextarea.addEventListener("input", () => { state.envVars = envTextarea.value; }); + + // ─── http/sse fields ─── + const httpSection = form.createDiv({ cls: "af-create-section" }); + const httpHeader = httpSection.createDiv({ cls: "af-create-section-header" }); + const httpIcon = httpHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(httpIcon, "globe"); + httpHeader.createSpan({ text: "Remote Server Configuration" }); + + deps.createFormField(httpSection, "URL", "https://mcp.example.com/sse", "Server endpoint URL", (v) => { state.url = v; }); + + const headersLabel = httpSection.createDiv({ cls: "af-form-label" }); + headersLabel.setText("Custom headers"); + deps.addTooltip(headersLabel, "One Header: Value per line (optional, non-secret)"); + const headersTextarea = httpSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "X-Custom-Header: value", rows: "2" }, + }); + headersTextarea.addEventListener("input", () => { state.headers = headersTextarea.value; }); + + // Auth dropdown + const authRow = httpSection.createDiv({ cls: "af-form-row" }); + const authLabel = authRow.createDiv({ cls: "af-form-label" }); + authLabel.setText("Authentication"); + deps.addTooltip(authLabel, "none, a static bearer token, or OAuth (authenticate after saving)"); + const authSelect = authRow.createEl("select", { cls: "af-form-select" }); + authSelect.createEl("option", { text: "None", attr: { value: "none" } }); + authSelect.createEl("option", { text: "Bearer token", attr: { value: "bearer" } }); + authSelect.createEl("option", { text: "OAuth", attr: { value: "oauth" } }); + + // Bearer token field (shown only for auth=bearer). Stored in the keychain, + // never in the vault. + const bearerRow = httpSection.createDiv({ cls: "af-form-row" }); + const bearerLabel = bearerRow.createDiv({ cls: "af-form-label" }); + bearerLabel.setText("Bearer token"); + deps.addTooltip(bearerLabel, "Stored securely in the OS keychain, never written to the vault"); + const bearerInput = bearerRow.createEl("input", { cls: "af-form-input", attr: { type: "password", placeholder: "sk-…" } }); + bearerInput.addEventListener("input", () => { state.bearerToken = bearerInput.value; }); + + const updateAuthVisibility = () => { + bearerRow.setCssStyles({ display: state.auth === "bearer" ? "" : "none" }); + }; + authSelect.addEventListener("change", () => { + state.auth = authSelect.value as "none" | "bearer" | "oauth"; + updateAuthVisibility(); + }); + updateAuthVisibility(); + + // Toggle section visibility based on transport + const updateTransportVisibility = () => { + stdioSection.setCssStyles({ display: state.transport === "stdio" ? "" : "none" }); + httpSection.setCssStyles({ display: state.transport !== "stdio" ? "" : "none" }); + }; + transportSelect.addEventListener("change", () => { + state.transport = transportSelect.value as "stdio" | "http" | "sse"; + updateTransportVisibility(); + }); + updateTransportVisibility(); + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("mcp"); + + const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(submitBtn, "plus", "af-btn-icon"); + submitBtn.appendText(" Add Server"); + submitBtn.onclick = async () => { + const name = state.name.trim(); + if (!name) { new Notice("Server name is required."); return; } + + if (state.transport === "stdio") { + if (!state.command.trim()) { new Notice("Command is required for stdio servers."); return; } + } else { + if (!state.url.trim()) { new Notice("URL is required for HTTP/SSE servers."); return; } + } + + // Parse env vars + const envVars: Record = {}; + if (state.envVars.trim()) { + for (const line of splitLines(state.envVars)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx <= 0) { new Notice(`Invalid env var: ${trimmed}`); return; } + envVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } + } + + // Parse headers + const headers: Record = {}; + if (state.headers.trim()) { + for (const line of splitLines(state.headers)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const colonIdx = trimmed.indexOf(":"); + if (colonIdx <= 0) { new Notice(`Invalid header: ${trimmed}`); return; } + headers[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim(); + } + } + + // Parse args + const args = state.args.trim() ? state.args.trim().split(/\s+/) : undefined; + + if (plugin.repository.getMcpServerByName(name)) { + new Notice(`An MCP server named "${name}" already exists.`); + return; + } + if (state.transport !== "stdio" && state.auth === "bearer" && !state.bearerToken.trim()) { + new Notice("Enter a bearer token, or choose a different auth method."); + return; + } + + submitBtn.disabled = true; + submitBtn.setText("Adding..."); + + // Build the registry definition (non-secret fields only). The bearer + // token, if any, goes to the keychain — never into the vault file. + const server: McpServer = { + name, + type: state.transport, + enabled: true, + source: "manual", + status: "disconnected", + scope: "user", + tools: [], + toolDetails: [], + }; + if (state.transport === "stdio") { + server.command = state.command.trim(); + if (args) server.args = args; + if (Object.keys(envVars).length > 0) server.env = envVars; + } else { + server.url = state.url.trim(); + if (Object.keys(headers).length > 0) server.headers = headers; + server.auth = state.auth; + } + + try { + await plugin.repository.saveMcpServer(server, state.description.trim()); + if (state.transport !== "stdio" && state.auth === "bearer" && state.bearerToken.trim()) { + plugin.mcpAuth.storeStaticToken(name, state.bearerToken.trim()); + } + new Notice(`Server "${name}" added.`); + await plugin.refreshFromVault(); + deps.navigate("mcp"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to add server: ${msg}`); + submitBtn.disabled = false; + submitBtn.setText(""); + createIcon(submitBtn, "plus", "af-btn-icon"); + submitBtn.appendText(" Add Server"); + } + }; +} diff --git a/src/views/forms/shared.ts b/src/views/forms/shared.ts new file mode 100644 index 0000000..ba5ac8c --- /dev/null +++ b/src/views/forms/shared.ts @@ -0,0 +1,68 @@ +import type AgentFleetPlugin from "../../main"; + +/** + * View helpers every extracted form page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. Each form module extends this with its own (narrowly typed) + * `navigate` delegate and any page-specific extras. + */ +export interface DashboardFormDeps { + plugin: AgentFleetPlugin; + /** Append a small info icon with a hover tooltip to a label element. */ + addTooltip: (labelEl: HTMLElement, text: string) => void; + /** Standard labelled text-input form row. */ + createFormField: ( + container: HTMLElement, + label: string, + placeholder: string, + desc: string, + onChange: (v: string) => void, + initialValue?: string, + ) => void; + /** Disable a submit button while an async save is in flight; returns a restore fn. */ + markSubmitBusy: ( + btn: HTMLButtonElement, + busyLabel: string, + iconName: string, + label: string, + ) => () => void; +} + +/** Decompose a cron expression into the schedule-picker widget's components. */ +export function parseCronComponents(cron: string): { + freq: string; + hour: number; + minute: number; + days: number[]; + dayOfMonth: number; +} { + const defaults = { freq: "daily", hour: 9, minute: 0, days: [1], dayOfMonth: 1 }; + if (!cron?.trim()) return defaults; + + const shortcutMap: Record = { + "*/5 * * * *": "every_5m", + "*/15 * * * *": "every_15m", + "*/30 * * * *": "every_30m", + "0 * * * *": "every_hour", + "0 */2 * * *": "every_2h", + }; + if (shortcutMap[cron]) return { ...defaults, freq: shortcutMap[cron] }; + + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return defaults; + const [min, hr, dom, , dow] = parts; + const h = parseInt(hr ?? "9", 10); + const m = parseInt(min ?? "0", 10); + + if (dom === "*" && dow === "*") return { ...defaults, freq: "daily", hour: h, minute: m }; + if (dom === "*" && dow === "1-5") return { ...defaults, freq: "weekdays", hour: h, minute: m }; + if (dom === "*" && dow !== "*") { + const days = (dow ?? "1").split(",").map((d) => parseInt(d, 10)); + return { ...defaults, freq: "weekly", hour: h, minute: m, days }; + } + if (dow === "*" && dom !== "*") { + return { ...defaults, freq: "monthly", hour: h, minute: m, dayOfMonth: parseInt(dom ?? "1", 10) }; + } + + return { ...defaults, hour: h, minute: m }; +} diff --git a/src/views/forms/skillForm.ts b/src/views/forms/skillForm.ts new file mode 100644 index 0000000..ce3cee8 --- /dev/null +++ b/src/views/forms/skillForm.ts @@ -0,0 +1,271 @@ +import { Notice, setIcon } from "obsidian"; +import { ConfirmModal } from "../../modals/confirmModal"; +import type { SkillConfig } from "../../types"; +import { slugify } from "../../utils/markdown"; +import { createIcon } from "../../utils/icons"; +import type { DashboardFormDeps } from "./shared"; + +/** View helpers the skill form borrows from the dashboard. */ +export interface SkillFormDeps extends DashboardFormDeps { + /** Navigate the dashboard to another page (post-save / cancel / delete). */ + navigate: (page: "skills") => void; +} + +/** + * Shared skill form used by both the Create Skill and Edit Skill pages. + * Pass `skill` to render in edit mode (pre-filled fields, read-only name, + * delete button, "Save Changes" submit); omit it for create mode (which + * additionally offers a template picker). + */ +export function renderSkillForm( + page: HTMLElement, + deps: SkillFormDeps, + skill?: SkillConfig, +): void { + const { plugin } = deps; + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(avatar, skill ? "edit" : "plus"); + const headerInfo = headerLeft.createDiv(); + if (skill) { + headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Skill: ${skill.name}` }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify skill definition" }); + } else { + headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Skill" }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Define a reusable skill for your agents" }); + } + + header.createDiv({ cls: "af-detail-header-actions" }); + // Form state (pre-filled in edit mode) + const state = { + name: "", + description: skill ? skill.description ?? "" : "", + tags: skill ? skill.tags.join(", ") : "", + body: skill ? skill.body : "", + toolsBody: skill ? skill.toolsBody : "", + referencesBody: skill ? skill.referencesBody : "", + examplesBody: skill ? skill.examplesBody : "", + }; + + const SKILL_TEMPLATES: Record = { + none: { label: "None", prompt: "" }, + cli: { label: "CLI Tool Wrapper", prompt: "You are using the {{tool}} CLI. All operations go through the wrapper script.\n\nRequirements:\n- Ensure required environment variables are set\n- Parse JSON responses for human-readable output\n- Confirm destructive operations before executing\n\nKey behaviors:\n- List existing items before making changes\n- Use --dry-run flags when available\n- Report errors clearly with suggested fixes" }, + api: { label: "API Integration", prompt: "You are integrating with the {{service}} API.\n\nBase URL: https://api.example.com/v1\nAuth: Bearer token via environment variable\n\nKey behaviors:\n- Always check rate limits before bulk operations\n- Handle pagination for list endpoints\n- Validate inputs before making requests\n- Parse and format JSON responses for readability" }, + review: { label: "Code Review", prompt: "You are a code review skill. Analyze code changes and provide structured feedback.\n\nReview checklist:\n- Correctness: Does the code do what it claims?\n- Security: Any injection, auth, or data exposure risks?\n- Performance: Unnecessary allocations, N+1 queries, missing indexes?\n- Maintainability: Clear naming, reasonable complexity, adequate tests?\n\nOutput format:\n- Start with a 1-line summary\n- Group findings by severity (critical, warning, suggestion)\n- Reference specific file paths and line numbers" }, + data: { label: "Data Analysis", prompt: "You are a data analysis skill. Query, transform, and report on data.\n\nKey behaviors:\n- Summarize datasets before diving into details\n- Use tables and charts where appropriate\n- Always state the time range and filters applied\n- Flag anomalies and outliers explicitly\n- End with actionable insights, not just observations" }, + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Identity Section ─── + const identitySection = form.createDiv({ cls: "af-create-section" }); + const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" }); + const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(identityIcon, "puzzle"); + identityHeader.createSpan({ text: "Identity" }); + + if (skill) { + // Name (read-only) + const nameRow = identitySection.createDiv({ cls: "af-form-row" }); + nameRow.createDiv({ cls: "af-form-label", text: "Name" }); + const nameInput = nameRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", value: skill.name, disabled: "true" }, + }); + nameInput.setCssStyles({ opacity: "0.6" }); + } else { + deps.createFormField(identitySection, "Name", "todoist", "Unique identifier (will be slugified)", (v) => { state.name = v; }); + } + + deps.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; }, skill ? skill.description ?? "" : undefined); + deps.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; }, skill ? skill.tags.join(", ") : undefined); + + // ─── Core Instructions Section ─── + const instructionsSection = form.createDiv({ cls: "af-create-section" }); + const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); + const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(instructionsIcon, "file-text"); + instructionsHeader.createSpan({ text: "Core Instructions" }); + + // Template picker (create mode only) + let templateSelect: HTMLSelectElement | undefined; + if (!skill) { + const templateRow = instructionsSection.createDiv({ cls: "af-form-row" }); + templateRow.createDiv({ cls: "af-form-label", text: "Template" }); + templateSelect = templateRow.createEl("select", { cls: "af-form-select" }); + for (const [key, { label }] of Object.entries(SKILL_TEMPLATES)) { + templateSelect.createEl("option", { text: label, attr: { value: key } }); + } + } + + const bodyTextarea = instructionsSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { + placeholder: skill + ? "Skill instructions..." + : "Skill instructions — what does this skill do and how should agents use it?", + rows: "10", + }, + }); + if (skill) bodyTextarea.value = skill.body; + bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; }); + + if (templateSelect) { + const select = templateSelect; + select.addEventListener("change", () => { + const preset = SKILL_TEMPLATES[select.value]; + if (preset && select.value !== "none") { + state.body = preset.prompt; + bodyTextarea.value = preset.prompt; + } + }); + } + + // ─── Tools Section ─── + const toolsSection = form.createDiv({ cls: "af-create-section" }); + const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" }); + const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(toolsIcon, "wrench"); + const toolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" }); + deps.addTooltip(toolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill"); + const toolsTextarea = toolsSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { + placeholder: skill + ? "## Commands\n\n### list\n..." + : "## Commands\n\n### list\nUsage: tool list [--filter ]\n...", + rows: "8", + }, + }); + if (skill) toolsTextarea.value = skill.toolsBody; + toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; }); + + // ─── References Section ─── + const refsSection = form.createDiv({ cls: "af-create-section" }); + const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" }); + const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(refsIcon, "book-open"); + const refsHeaderLabel = refsHeader.createSpan({ text: "References" }); + deps.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets"); + const refsTextarea = refsSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" }, + }); + if (skill) refsTextarea.value = skill.referencesBody; + refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; }); + + // ─── Examples Section ─── + const examplesSection = form.createDiv({ cls: "af-create-section" }); + const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" }); + const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(examplesIcon, "message-circle"); + const examplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" }); + deps.addTooltip(examplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill"); + const examplesTextarea = examplesSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { + placeholder: skill + ? "## Example: List all tasks\n..." + : "## Example: List all tasks\n\nUser: Show me my tasks for today\n\nAgent: ...", + rows: "6", + }, + }); + if (skill) examplesTextarea.value = skill.examplesBody; + examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; }); + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + + if (skill) { + const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); + createIcon(deleteBtn, "trash-2", "af-btn-icon"); + deleteBtn.appendText(" Delete"); + deleteBtn.onclick = () => { + new ConfirmModal(plugin.app, { + title: `Delete skill "${skill.name}"?`, + body: "The skill file will be moved to your system trash and can be recovered.", + confirmText: "Delete", + danger: true, + onConfirm: async () => { + await plugin.repository.deleteSkill(skill.name); + new Notice(`Skill "${skill.name}" deleted.`); + // Small delay for Obsidian vault cache to process the trash + await new Promise((r) => window.setTimeout(r, 200)); + await plugin.refreshFromVault(); + deps.navigate("skills"); + }, + }).open(); + }; + + footer.createDiv({ cls: "af-toolbar-spacer" }); + } + + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("skills"); + + const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(submitBtn, skill ? "check" : "plus", "af-btn-icon"); + submitBtn.appendText(skill ? " Save Changes" : " Create Skill"); + + if (skill) { + // Edit mode: update the existing skill file(s) in place. + submitBtn.onclick = async () => { + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + const restoreSubmit = deps.markSubmitBusy(submitBtn, "Saving...", "check", "Save Changes"); + try { + await plugin.repository.updateSkill(skill.name, { + description: state.description.trim(), + tags: parseTags(state.tags), + body: state.body.trim(), + toolsBody: state.toolsBody.trim(), + referencesBody: state.referencesBody.trim(), + examplesBody: state.examplesBody.trim(), + }); + new Notice(`Skill "${skill.name}" updated.`); + await plugin.refreshFromVault(); + deps.navigate("skills"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to update skill: ${msg}`); + restoreSubmit(); + } + }; + } else { + // Create mode: write a new skill folder. + submitBtn.onclick = async () => { + const name = state.name.trim(); + if (!name) { + new Notice("Skill name is required."); + return; + } + const slug = slugify(name); + if (plugin.repository.getSkillByName(slug)) { + new Notice(`Skill "${slug}" already exists.`); + return; + } + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + const restoreSubmit = deps.markSubmitBusy(submitBtn, "Creating...", "plus", "Create Skill"); + try { + await plugin.repository.createSkillFolder({ + name: slug, + description: state.description.trim(), + tags: parseTags(state.tags), + body: state.body.trim(), + toolsBody: state.toolsBody.trim(), + referencesBody: state.referencesBody.trim(), + examplesBody: state.examplesBody.trim(), + }); + new Notice(`Skill "${slug}" created.`); + await plugin.refreshFromVault(); + deps.navigate("skills"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create skill: ${msg}`); + restoreSubmit(); + } + }; + } +} diff --git a/src/views/forms/taskForm.ts b/src/views/forms/taskForm.ts new file mode 100644 index 0000000..b597e7c --- /dev/null +++ b/src/views/forms/taskForm.ts @@ -0,0 +1,786 @@ +import { Notice, setIcon } from "obsidian"; +import { ConfirmModal } from "../../modals/confirmModal"; +import type { ChannelConfig, TaskConfig } from "../../types"; +import { slugify, stringifyMarkdownWithFrontmatter } from "../../utils/markdown"; +import { createIcon } from "../../utils/icons"; +import { renderModelPicker } from "../../components/modelPicker"; +import type { DashboardFormDeps } from "./shared"; +import { parseCronComponents } from "./shared"; + +/** View helpers the task forms borrow from the dashboard. */ +export interface TaskFormDeps extends DashboardFormDeps { + /** Navigate the dashboard to another page (post-save / cancel / delete). */ + navigate: (page: "kanban" | "task-detail", context?: string) => void; +} + +/** Inline cron schedule picker shared by the create and edit task forms. + * Writes the built cron expression into `state.schedule`. */ +function renderInlineSchedule( + container: HTMLElement, + state: { schedule: string; type: string }, +): void { + // Parse current cron into components + const parsed = parseCronComponents(state.schedule); + + // Frequency dropdown + const freqRow = container.createDiv({ cls: "af-form-row" }); + freqRow.createDiv({ cls: "af-form-label", text: "Frequency" }); + const freqSelect = freqRow.createEl("select", { cls: "af-form-select" }); + const freqOptions: Array<[string, string]> = [ + ["every_5m", "Every 5 minutes"], + ["every_15m", "Every 15 minutes"], + ["every_30m", "Every 30 minutes"], + ["every_hour", "Every hour"], + ["every_2h", "Every 2 hours"], + ["daily", "Daily"], + ["weekdays", "Weekdays"], + ["weekly", "Weekly"], + ["monthly", "Monthly"], + ]; + for (const [val, lbl] of freqOptions) { + const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === parsed.freq) opt.selected = true; + } + + // Time row (shown for daily/weekdays/weekly/monthly) + const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" }); + timeRow.createDiv({ cls: "af-form-label", text: "Time" }); + const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" }); + + const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); + for (let h = 0; h < 24; h++) { + const ampm = h >= 12 ? "PM" : "AM"; + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; + const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } }); + if (h === parsed.hour) opt.selected = true; + } + + timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" }); + + const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" }); + for (let m = 0; m < 60; m += 5) { + const opt = minSelect.createEl("option", { + text: String(m).padStart(2, "0"), + attr: { value: String(m) }, + }); + if (m === parsed.minute) opt.selected = true; + } + + // Day row (shown for weekly) + const dayRow = container.createDiv({ cls: "af-form-row af-schedule-day-row" }); + dayRow.createDiv({ cls: "af-form-label", text: "Day" }); + const dayWrap = dayRow.createDiv({ cls: "af-schedule-day-buttons" }); + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const selectedDays = new Set(parsed.days); + + for (let d = 0; d < 7; d++) { + const btn = dayWrap.createEl("button", { + cls: `af-schedule-day-btn${selectedDays.has(d) ? " active" : ""}`, + text: dayNames[d]!, + }); + btn.onclick = () => { + if (selectedDays.has(d)) selectedDays.delete(d); + else selectedDays.add(d); + btn.toggleClass("active", selectedDays.has(d)); + buildCron(); + }; + } + + // Day-of-month row (shown for monthly) + const domRow = container.createDiv({ cls: "af-form-row af-schedule-dom-row" }); + domRow.createDiv({ cls: "af-form-label", text: "Day of month" }); + const domSelect = domRow.createEl("select", { cls: "af-form-select af-form-select-sm" }); + for (let d = 1; d <= 28; d++) { + const opt = domSelect.createEl("option", { text: String(d), attr: { value: String(d) } }); + if (d === parsed.dayOfMonth) opt.selected = true; + } + + // Visibility logic + const showHideFields = () => { + const freq = freqSelect.value; + const needsTime = ["daily", "weekdays", "weekly", "monthly"].includes(freq); + const needsDay = freq === "weekly"; + const needsDom = freq === "monthly"; + timeRow.setCssStyles({ display: needsTime ? "" : "none" }); + dayRow.setCssStyles({ display: needsDay ? "" : "none" }); + domRow.setCssStyles({ display: needsDom ? "" : "none" }); + }; + + // Build cron from selections + const buildCron = () => { + const freq = freqSelect.value; + const h = hourSelect.value; + const m = minSelect.value; + let cron = ""; + switch (freq) { + case "every_5m": cron = "*/5 * * * *"; break; + case "every_15m": cron = "*/15 * * * *"; break; + case "every_30m": cron = "*/30 * * * *"; break; + case "every_hour": cron = "0 * * * *"; break; + case "every_2h": cron = "0 */2 * * *"; break; + case "daily": cron = `${m} ${h} * * *`; break; + case "weekdays": cron = `${m} ${h} * * 1-5`; break; + case "weekly": { + const days = Array.from(selectedDays).sort().join(",") || "1"; + cron = `${m} ${h} * * ${days}`; + break; + } + case "monthly": cron = `${m} ${h} ${domSelect.value} * *`; break; + } + state.schedule = cron; + state.type = "recurring"; + }; + + freqSelect.addEventListener("change", () => { showHideFields(); buildCron(); }); + hourSelect.addEventListener("change", buildCron); + minSelect.addEventListener("change", buildCron); + domSelect.addEventListener("change", buildCron); + + showHideFields(); +} + +/** + * Render the optional "post results to a channel" controls shared by the + * create and edit task forms: a channel picker plus a transport-native target + * id (Discord/Slack channel id, Telegram chat id). Empty target = broadcast/DM. + */ +function renderTaskChannelDelivery( + section: HTMLElement, + deps: TaskFormDeps, + channels: ChannelConfig[], + state: { channel: string; channelTarget: string }, +): void { + const channelRow = section.createDiv({ cls: "af-form-row" }); + const channelLabel = channelRow.createDiv({ cls: "af-form-label", text: "Channel" }); + const channelSelect = channelRow.createEl("select", { cls: "af-form-select" }); + channelSelect.createEl("option", { text: "— none (run log only) —", attr: { value: "" } }); + for (const ch of channels) { + const opt = channelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } }); + if (ch.name === state.channel) opt.selected = true; + } + deps.addTooltip( + channelLabel, + "Post this task’s full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).", + ); + + const targetRow = section.createDiv({ cls: "af-form-row" }); + const targetLabel = targetRow.createDiv({ cls: "af-form-label", text: "Target ID" }); + const targetInput = targetRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", placeholder: "Discord/Slack channel ID or Telegram chat ID — empty = DM you" }, + }); + targetInput.value = state.channelTarget; + targetInput.addEventListener("input", () => { state.channelTarget = targetInput.value.trim(); }); + deps.addTooltip( + targetLabel, + "Where in the channel to post. For Discord, enable Developer Mode and right-click the channel → Copy Channel ID. Leave empty to DM the first allowed user instead.", + ); + + const syncVisibility = () => { targetRow.setCssStyles({ display: state.channel ? "" : "none" }); }; + syncVisibility(); + channelSelect.addEventListener("change", () => { + state.channel = channelSelect.value; + syncVisibility(); + }); +} + +function toLocalISO(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +/** Format a Date as the value string accepted by ``: + * `YYYY-MM-DDTHH:mm`, local timezone, no seconds. */ +function toDatetimeLocal(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +type ScheduleChoice = "immediate" | "recurring" | "once"; + +/** + * Segmented [ Immediate | Recurring | One-time ] control that drives the + * schedule sections. Maps 1:1 onto the previous enable-toggle + mode-dropdown + * state (scheduleEnabled + scheduleMode), so the saved frontmatter is + * unchanged — only the input UI differs. + */ +function renderScheduleTypeSegments( + container: HTMLElement, + initial: ScheduleChoice, + onSelect: (choice: ScheduleChoice) => void, +): void { + const row = container.createDiv({ cls: "af-form-row" }); + row.createDiv({ cls: "af-form-label", text: "Run" }); + const seg = row.createDiv({ cls: "af-segmented" }); + const options: Array<[ScheduleChoice, string]> = [ + ["immediate", "Immediate"], + ["recurring", "Recurring"], + ["once", "One-time"], + ]; + const buttons: Array<[ScheduleChoice, HTMLButtonElement]> = []; + for (const [val, lbl] of options) { + const btn = seg.createEl("button", { + cls: `af-segmented-btn${val === initial ? " active" : ""}`, + text: lbl, + }); + buttons.push([val, btn]); + btn.onclick = () => { + for (const [v, b] of buttons) b.toggleClass("active", v === val); + onSelect(val); + }; + } +} + +// ═══════════════════════════════════════════════════════ +// Create Task Page +// ═══════════════════════════════════════════════════════ + +export function renderCreateTaskForm(page: HTMLElement, deps: TaskFormDeps): void { + const { plugin } = deps; + const snapshot = plugin.runtime.getSnapshot(); + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(avatar, "plus"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Task" }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new task for your fleet" }); + + header.createDiv({ cls: "af-detail-header-actions" }); + + // Form state + const state = { + title: "", + agent: snapshot.agents[0]?.name ?? "", + priority: "medium", + tags: "", + body: "", + scheduleEnabled: false, + /** "recurring" (cron) or "once" (run_at). Only consulted when + * scheduleEnabled is true; otherwise the task is "immediate". */ + scheduleMode: "recurring" as "recurring" | "once", + schedule: "0 9 * * *", + runAt: "", + type: "immediate" as string, + enabled: true, + catchUp: true, + effort: "", + model: "", + channel: "", + channelTarget: "", + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Task Details Section ─── + const detailsSection = form.createDiv({ cls: "af-create-section" }); + const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); + const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(detailsIcon, "file-text"); + detailsHeader.createSpan({ text: "Task Details" }); + + deps.createFormField(detailsSection, "Title", "Daily status report", "Used as the task identifier", (v) => { state.title = v; }); + + // Agent dropdown + const agentRow = detailsSection.createDiv({ cls: "af-form-row" }); + agentRow.createDiv({ cls: "af-form-label", text: "Agent" }); + const agentSelect = agentRow.createEl("select", { cls: "af-form-select" }); + for (const a of snapshot.agents) { + agentSelect.createEl("option", { text: a.name, attr: { value: a.name } }); + } + agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; }); + + // Priority dropdown + const priorityRow = detailsSection.createDiv({ cls: "af-form-row" }); + priorityRow.createDiv({ cls: "af-form-label", text: "Priority" }); + const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" }); + const priorityOptions: Array<[string, string]> = [ + ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"], + ]; + for (const [val, lbl] of priorityOptions) { + const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === "medium") opt.selected = true; + } + prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value; }); + + // Tags + deps.createFormField(detailsSection, "Tags", "monitoring, devops", "Comma-separated", (v) => { state.tags = v; }); + + // ─── Instructions Section ─── + const instructionsSection = form.createDiv({ cls: "af-create-section" }); + const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); + const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(instructionsIcon, "message-square"); + instructionsHeader.createSpan({ text: "Instructions" }); + + const instructionsTextarea = instructionsSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "Describe what the agent should do...", rows: "10" }, + }); + instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; }); + + // ─── Schedule Section ─── + const scheduleSection = form.createDiv({ cls: "af-create-section" }); + const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" }); + const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(scheduleIcon, "clock"); + scheduleHeader.createSpan({ text: "Schedule" }); + + // Schedule type — one segmented control replaces the old enable-toggle + + // mode dropdown. Drives the same state fields (scheduleEnabled/scheduleMode) + // so the saved frontmatter is identical. + renderScheduleTypeSegments(scheduleSection, "immediate", (choice) => { + state.scheduleEnabled = choice !== "immediate"; + if (choice !== "immediate") state.scheduleMode = choice; + state.type = choice; + scheduleBody.setCssStyles({ display: choice === "immediate" ? "none" : "" }); + cronHost.setCssStyles({ display: choice === "recurring" ? "" : "none" }); + onceHost.setCssStyles({ display: choice === "once" ? "" : "none" }); + }); + + const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" }); + scheduleBody.setCssStyles({ display: "none" }); + const cronHost = scheduleBody.createDiv(); + const onceHost = scheduleBody.createDiv(); + onceHost.setCssStyles({ display: "none" }); + renderInlineSchedule(cronHost, state); + + const onceRow = onceHost.createDiv({ cls: "af-form-row" }); + onceRow.createDiv({ cls: "af-form-label", text: "Run at" }); + const onceInput = onceRow.createEl("input", { + cls: "af-form-input", + attr: { type: "datetime-local", value: toDatetimeLocal(new Date(Date.now() + 3600_000)) }, + }); + // Seed the initial value — the task won't persist blank runAt + state.runAt = new Date(onceInput.value).toISOString(); + onceInput.addEventListener("input", () => { + state.runAt = onceInput.value ? new Date(onceInput.value).toISOString() : ""; + }); + + // Enabled toggle (only when schedule is on) + const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); + enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" }); + enabledToggle.onclick = () => { + const isOn = enabledToggle.hasClass("on"); + enabledToggle.toggleClass("on", !isOn); + state.enabled = !isOn; + }; + + // Catch up missed runs toggle + const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" }); + catchUpLabel.setText("Catch up if missed"); + + const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` }); + catchUpToggle.onclick = () => { + const isOn = catchUpToggle.hasClass("on"); + catchUpToggle.toggleClass("on", !isOn); + state.catchUp = !isOn; + }; + + // ─── Execution Section ─── + // Model & effort are per-run controls, not scheduling knobs — they apply + // whether or not the task has a schedule, so they live in their own section. + const execSection = form.createDiv({ cls: "af-create-section" }); + const execHeader = execSection.createDiv({ cls: "af-create-section-header" }); + const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(execIcon, "gauge"); + execHeader.createSpan({ text: "Execution" }); + + // Model override (per-task) + const taskModelRow = execSection.createDiv({ cls: "af-form-row" }); + const taskModelLabel = taskModelRow.createDiv({ cls: "af-form-label", text: "Model" }); + const taskModelFieldWrap = taskModelRow.createDiv({ cls: "af-form-field-wrap" }); + const renderCreateTaskModelPicker = (agentName: string) => { + taskModelFieldWrap.empty(); + const selAgent = snapshot.agents.find((a) => a.name === agentName); + renderModelPicker(taskModelFieldWrap, { + value: state.model, + adapter: selAgent?.adapter, + onChange: (value) => { state.model = value; }, + allowInherit: true, + inheritPlaceholder: selAgent + ? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}` + : "Inherit from agent", + }); + }; + renderCreateTaskModelPicker(state.agent); + agentSelect.addEventListener("change", () => renderCreateTaskModelPicker(agentSelect.value)); + deps.addTooltip( + taskModelLabel, + "Override the agent’s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.", + ); + + // Effort level + const taskEffortRow = execSection.createDiv({ cls: "af-form-row" }); + const taskEffortLabel = taskEffortRow.createDiv({ cls: "af-form-label", text: "Effort" }); + const taskEffortSelect = taskEffortRow.createEl("select", { cls: "af-form-select" }); + for (const [val, lbl] of [["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) { + const opt = taskEffortSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === state.effort) opt.selected = true; + } + taskEffortSelect.addEventListener("change", () => { state.effort = taskEffortSelect.value; }); + // Channel delivery (optional) — post this task's output to a channel on completion. + renderTaskChannelDelivery(execSection, deps, snapshot.channels, state); + deps.addTooltip( + taskEffortLabel, + "Overrides the agent’s effort level for this task. Higher effort = more thinking tokens spent.", + ); + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("kanban"); + + const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(createBtn, "plus", "af-btn-icon"); + createBtn.appendText(" Create Task"); + createBtn.onclick = async () => { + const title = state.title.trim(); + if (!title) { + new Notice("Task title is required."); + return; + } + const taskId = slugify(title); + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + + const effectiveType = state.scheduleEnabled + ? state.scheduleMode === "once" + ? "once" + : "recurring" + : "immediate"; + + const frontmatter: Record = { + task_id: taskId, + agent: state.agent, + type: effectiveType, + priority: state.priority, + enabled: state.enabled, + created: toLocalISO(new Date()), + run_count: 0, + catch_up: state.catchUp, + effort: state.effort || undefined, + model: state.model || undefined, + channel: state.channel || undefined, + channel_target: state.channel && state.channelTarget ? state.channelTarget : undefined, + tags: parseTags(state.tags), + }; + + if (effectiveType === "recurring") { + frontmatter.schedule = state.schedule.trim() || "0 9 * * *"; + } else if (effectiveType === "once") { + if (!state.runAt) { + new Notice("Pick a date/time for the one-time run."); + return; + } + frontmatter.run_at = state.runAt; + } + + const restoreSubmit = deps.markSubmitBusy(createBtn, "Creating...", "plus", "Create Task"); + try { + const path = await plugin.repository.getAvailablePath( + plugin.repository.getSubfolder("tasks"), + taskId, + ); + await plugin.app.vault.create( + path, + stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim() || "Describe the task here."), + ); + new Notice(`Task "${taskId}" created.`); + await plugin.refreshFromVault(); + deps.navigate("task-detail", taskId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to create task: ${msg}`); + restoreSubmit(); + } + }; +} + +// ═══════════════════════════════════════════════════════ +// Edit Task Page +// ═══════════════════════════════════════════════════════ + +export function renderEditTaskForm(page: HTMLElement, deps: TaskFormDeps, task: TaskConfig): void { + const { plugin } = deps; + const snapshot = plugin.runtime.getSnapshot(); + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(taskIcon, "edit"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Task: ${task.taskId}` }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify task configuration" }); + + header.createDiv({ cls: "af-detail-header-actions" }); + + const hasSchedule = !!(task.schedule || task.runAt); + + // Form state pre-filled + const state = { + agent: task.agent, + type: task.type as string, + priority: task.priority, + schedule: task.schedule ?? "0 9 * * *", + runAt: task.runAt ?? "", + scheduleEnabled: hasSchedule, + scheduleMode: (task.type === "once" ? "once" : "recurring"), + enabled: task.enabled, + catchUp: task.catchUp, + effort: task.effort ?? "", + model: task.model ?? "", + channel: task.channel ?? "", + channelTarget: task.channelTarget ?? "", + tags: task.tags.join(", "), + body: task.body, + }; + + const form = page.createDiv({ cls: "af-create-form" }); + + // ─── Task Details Section ─── + const detailsSection = form.createDiv({ cls: "af-create-section" }); + const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" }); + const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(detailsIcon, "file-text"); + detailsHeader.createSpan({ text: "Task Details" }); + + // Title (read-only) + const nameRow = detailsSection.createDiv({ cls: "af-form-row" }); + nameRow.createDiv({ cls: "af-form-label", text: "Title" }); + const nameInput = nameRow.createEl("input", { + cls: "af-form-input", + attr: { type: "text", value: task.taskId, disabled: "true" }, + }); + nameInput.setCssStyles({ opacity: "0.6" }); + + // Agent dropdown + const agentRow = detailsSection.createDiv({ cls: "af-form-row" }); + agentRow.createDiv({ cls: "af-form-label", text: "Agent" }); + const agentSelect = agentRow.createEl("select", { cls: "af-form-select" }); + for (const a of snapshot.agents) { + const opt = agentSelect.createEl("option", { text: a.name, attr: { value: a.name } }); + if (a.name === task.agent) opt.selected = true; + } + agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; }); + + // Priority dropdown + const priorityRow = detailsSection.createDiv({ cls: "af-form-row" }); + priorityRow.createDiv({ cls: "af-form-label", text: "Priority" }); + const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" }); + const priorityOptions: Array<[string, string]> = [ + ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"], + ]; + for (const [val, lbl] of priorityOptions) { + const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === task.priority) opt.selected = true; + } + prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value as typeof state.priority; }); + + // Tags + deps.createFormField(detailsSection, "Tags", "monitoring, critical", "Comma-separated", (v) => { state.tags = v; }, task.tags.join(", ")); + + // ─── Instructions Section ─── + const instructionsSection = form.createDiv({ cls: "af-create-section" }); + const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" }); + const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(instructionsIcon, "message-square"); + instructionsHeader.createSpan({ text: "Instructions" }); + + const instructionsTextarea = instructionsSection.createEl("textarea", { + cls: "af-create-prompt-textarea", + attr: { placeholder: "Describe what the agent should do...", rows: "10" }, + }); + instructionsTextarea.value = task.body; + instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; }); + + // ─── Schedule Section ─── + const scheduleSection = form.createDiv({ cls: "af-create-section" }); + const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" }); + const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(scheduleIcon, "clock"); + scheduleHeader.createSpan({ text: "Schedule" }); + + // Schedule type — one segmented control replaces the old enable-toggle + + // mode dropdown. Drives the same state fields (scheduleEnabled/scheduleMode) + // so the saved frontmatter is identical. + const initialChoice: ScheduleChoice = !hasSchedule + ? "immediate" + : state.scheduleMode === "once" + ? "once" + : "recurring"; + renderScheduleTypeSegments(scheduleSection, initialChoice, (choice) => { + state.scheduleEnabled = choice !== "immediate"; + if (choice !== "immediate") state.scheduleMode = choice; + state.type = choice; + scheduleBody.setCssStyles({ display: choice === "immediate" ? "none" : "" }); + editCronHost.setCssStyles({ display: choice === "recurring" ? "" : "none" }); + editOnceHost.setCssStyles({ display: choice === "once" ? "" : "none" }); + }); + + const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" }); + scheduleBody.setCssStyles({ display: hasSchedule ? "" : "none" }); + const editCronHost = scheduleBody.createDiv(); + const editOnceHost = scheduleBody.createDiv(); + editCronHost.setCssStyles({ display: initialChoice === "recurring" ? "" : "none" }); + editOnceHost.setCssStyles({ display: initialChoice === "once" ? "" : "none" }); + renderInlineSchedule(editCronHost, state); + + const editOnceRow = editOnceHost.createDiv({ cls: "af-form-row" }); + editOnceRow.createDiv({ cls: "af-form-label", text: "Run at" }); + const prefillOnce = state.runAt + ? toDatetimeLocal(new Date(state.runAt)) + : toDatetimeLocal(new Date(Date.now() + 3600_000)); + const editOnceInput = editOnceRow.createEl("input", { + cls: "af-form-input", + attr: { type: "datetime-local", value: prefillOnce }, + }); + if (!state.runAt) state.runAt = new Date(editOnceInput.value).toISOString(); + editOnceInput.addEventListener("input", () => { + state.runAt = editOnceInput.value ? new Date(editOnceInput.value).toISOString() : ""; + }); + + // Enabled toggle (whether the schedule is active) + const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); + enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" }); + const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${task.enabled ? " on" : ""}` }); + enabledToggle.onclick = () => { + const isOn = enabledToggle.hasClass("on"); + enabledToggle.toggleClass("on", !isOn); + state.enabled = !isOn; + }; + + // Catch up missed runs toggle + const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" }); + const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" }); + catchUpLabel.setText("Catch up if missed"); + + const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` }); + catchUpToggle.onclick = () => { + const isOn = catchUpToggle.hasClass("on"); + catchUpToggle.toggleClass("on", !isOn); + state.catchUp = !isOn; + }; + + // ─── Execution Section ─── + const execSection = form.createDiv({ cls: "af-create-section" }); + const execHeader = execSection.createDiv({ cls: "af-create-section-header" }); + const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" }); + setIcon(execIcon, "gauge"); + execHeader.createSpan({ text: "Execution" }); + + // Effort level + const effortRow = execSection.createDiv({ cls: "af-form-row" }); + const effortLabel = effortRow.createDiv({ cls: "af-form-label", text: "Effort" }); + const effortSelect = effortRow.createEl("select", { cls: "af-form-select" }); + const effortOptions: Array<[string, string]> = [ + ["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"], + ]; + for (const [val, lbl] of effortOptions) { + const opt = effortSelect.createEl("option", { text: lbl, attr: { value: val } }); + if (val === state.effort) opt.selected = true; + } + effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; }); + deps.addTooltip( + effortLabel, + "Overrides the agent’s effort level for this task. Higher effort = more thinking tokens spent.", + ); + + // Model override + const modelRowEdit = execSection.createDiv({ cls: "af-form-row" }); + const modelLabelEdit = modelRowEdit.createDiv({ cls: "af-form-label", text: "Model" }); + const modelFieldWrapEdit = modelRowEdit.createDiv({ cls: "af-form-field-wrap" }); + const renderTaskModelPicker = (agentName: string) => { + modelFieldWrapEdit.empty(); + const selAgent = snapshot.agents.find((a) => a.name === agentName); + renderModelPicker(modelFieldWrapEdit, { + value: state.model, + adapter: selAgent?.adapter, + onChange: (value) => { state.model = value; }, + allowInherit: true, + inheritPlaceholder: selAgent + ? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}` + : "Inherit from agent", + }); + }; + renderTaskModelPicker(state.agent); + agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value)); + // Channel delivery (optional) — post this task's output to a channel on completion. + renderTaskChannelDelivery(execSection, deps, snapshot.channels, state); + deps.addTooltip( + modelLabelEdit, + "Override the agent’s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.", + ); + + // ─── Footer ─── + const footer = page.createDiv({ cls: "af-create-footer" }); + + const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" }); + createIcon(deleteBtn, "trash-2", "af-btn-icon"); + deleteBtn.appendText(" Delete"); + deleteBtn.onclick = () => { + new ConfirmModal(plugin.app, { + title: `Delete task "${task.taskId}"?`, + body: "The task file will be moved to your system trash and can be recovered.", + confirmText: "Delete", + danger: true, + onConfirm: async () => { + await plugin.repository.deleteTask(task.taskId); + new Notice(`Task "${task.taskId}" deleted.`); + await new Promise((r) => window.setTimeout(r, 200)); + await plugin.refreshFromVault(); + deps.navigate("kanban"); + }, + }).open(); + }; + + footer.createDiv({ cls: "af-toolbar-spacer" }); + + const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" }); + cancelBtn.onclick = () => deps.navigate("task-detail", task.taskId); + + const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" }); + createIcon(saveBtn, "check", "af-btn-icon"); + saveBtn.appendText(" Save Changes"); + saveBtn.onclick = async () => { + const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean); + const effectiveType = state.scheduleEnabled + ? state.scheduleMode === "once" + ? "once" + : "recurring" + : "immediate"; + if (effectiveType === "once" && !state.runAt) { + new Notice("Pick a date/time for the one-time run."); + return; + } + const restoreSubmit = deps.markSubmitBusy(saveBtn, "Saving...", "check", "Save Changes"); + try { + await plugin.repository.updateTask(task.taskId, { + agent: state.agent, + type: effectiveType, + priority: state.priority, + schedule: effectiveType === "recurring" ? state.schedule.trim() : "", + runAt: effectiveType === "once" ? state.runAt : "", + enabled: state.enabled, + catch_up: state.catchUp, + effort: state.effort || undefined, + model: state.model || "", + channel: state.channel || "", + channelTarget: state.channel && state.channelTarget ? state.channelTarget : "", + tags: parseTags(state.tags), + body: state.body.trim(), + }); + new Notice(`Task "${task.taskId}" updated.`); + await plugin.refreshFromVault(); + deps.navigate("task-detail", task.taskId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to update task: ${msg}`); + restoreSubmit(); + } + }; +} diff --git a/src/views/pages/agentDetailPage.ts b/src/views/pages/agentDetailPage.ts new file mode 100644 index 0000000..ddec0d5 --- /dev/null +++ b/src/views/pages/agentDetailPage.ts @@ -0,0 +1,396 @@ +import { Notice, setIcon } from "obsidian"; +import type { AgentConfig, AgentHealth, RunLogData, UsageRecord } from "../../types"; +import { truncate } from "../../utils/markdown"; +import { createIcon } from "../../utils/icons"; +import { renderSections } from "../../utils/memoryFormat"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the agent detail page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. The active tab is view state — the page reads and writes it + * through the get/set delegates so it survives re-renders. */ +export interface AgentDetailPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "edit-agent", context?: string) => void; + healthToClass: (status: AgentHealth) => string; + /** Lucide icon / emoji / initials avatar renderer (owned by the view). */ + renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void; + /** Dashboard stat card (owned by the view, shared with the overview page). */ + renderStatCard: ( + container: HTMLElement, + label: string, + value: string, + valueSuffix: string, + iconName: string, + sub: string, + ) => void; + /** Comprehensive token + cost totals across run logs and usage records. */ + combinedTotals: (runs: RunLogData[], usage: UsageRecord[]) => { tokens: number; cost: number }; + /** Run timeline entry shared with the overview page (owned by the view). */ + renderTimelineItem: (container: HTMLElement, run: RunLogData) => void; + /** Label + monospace value config row (owned by the view). */ + renderConfigRow: (container: HTMLElement, label: string, value: string) => void; + statusToTimelineClass: (status: string) => string; + statusToIconName: (status: string) => string; + statusToBadgeClass: (status: string) => string; + statusToBadgeText: (status: string) => string; + formatStarted: (iso: string) => string; + formatDuration: (seconds: number) => string; + formatTokenCount: (tokens: number) => string; + cronToHuman: (cron: string) => string; + timeUntil: (date: Date) => string; + /** Open the run-details slideover (owned by the view). */ + openSlideover: (run: RunLogData) => void; + /** Open the chat panel for this agent (owned by the view). */ + openChatSlideover: (agent: AgentConfig) => void; + /** Active detail tab — view state so it survives re-renders. */ + getDetailTab: () => string; + setDetailTab: (tab: string) => void; + /** Re-render the whole dashboard view (tab switches). */ + rerender: () => Promise; +} + +export function renderAgentDetailPage(container: HTMLElement, deps: AgentDetailPageDeps, agentName: string | undefined): void { + const page = container.createDiv({ cls: "af-agent-detail-page" }); + if (!agentName) { + deps.renderEmptyState(page, "bot", "No agent selected", "Select an agent from the list"); + return; + } + + const agent = deps.plugin.runtime.getSnapshot().agents.find((a) => a.name === agentName); + if (!agent) { + deps.renderEmptyState(page, "bot", "Agent not found", `Agent "${agentName}" was not found`); + return; + } + + const state = deps.plugin.runtime.getAgentState(agent.name); + const agentRuns = deps.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name); + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const avatar = headerLeft.createDiv({ + cls: `af-agent-card-avatar ${deps.healthToClass(state.status)}`, + }); + deps.renderAgentAvatar(avatar, agent); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: agent.name }); + headerInfo.createDiv({ cls: "af-detail-header-desc", text: agent.description ?? "No description" }); + + const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); + + // Chat — primary action + const chatBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(chatBtn, "message-circle", "af-btn-icon"); + chatBtn.appendText(" Chat"); + chatBtn.onclick = () => deps.openChatSlideover(agent); + + // Run Now / Enable — secondary + if (agent.enabled) { + const runBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); + createIcon(runBtn, "play", "af-btn-icon"); + runBtn.appendText(" Run Now"); + runBtn.onclick = () => void deps.plugin.runAgentPrompt(agent.name); + + const pauseBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); + createIcon(pauseBtn, "pause", "af-btn-icon"); + pauseBtn.appendText(" Disable"); + pauseBtn.onclick = () => void deps.plugin.toggleAgent(agent.name, false); + } else { + const enableBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); + createIcon(enableBtn, "play", "af-btn-icon"); + enableBtn.appendText(" Enable"); + enableBtn.onclick = () => void deps.plugin.toggleAgent(agent.name, true); + } + + const detailEditBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); + createIcon(detailEditBtn, "edit", "af-btn-icon"); + detailEditBtn.appendText(" Edit"); + detailEditBtn.onclick = () => deps.navigate("edit-agent", agent.name); + + const deleteBtn = headerActions.createEl("button", { cls: "af-btn-sm danger" }); + createIcon(deleteBtn, "trash-2", "af-btn-icon"); + deleteBtn.appendText(" Delete"); + deleteBtn.onclick = () => void deps.plugin.deleteAgent(agent.name); + + // Tabs + const tabs = page.createDiv({ cls: "af-detail-tabs" }); + const tabDefs = [ + { id: "overview", label: "Overview", icon: "layout-dashboard" }, + { id: "config", label: "Config", icon: "settings" }, + { id: "runs", label: "Runs", icon: "scroll-text" }, + { id: "memory", label: "Memory", icon: "file-text" }, + ]; + for (const t of tabDefs) { + const tabBtn = tabs.createEl("button", { + cls: `af-detail-tab${deps.getDetailTab() === t.id ? " active" : ""}`, + }); + createIcon(tabBtn, t.icon, "af-tab-icon"); + tabBtn.appendText(` ${t.label}`); + tabBtn.onclick = () => { + deps.setDetailTab(t.id); + void deps.rerender(); + }; + } + + const tabContent = page.createDiv({ cls: "af-detail-tab-content" }); + switch (deps.getDetailTab()) { + case "overview": + renderAgentOverviewTab(tabContent, deps, agent, agentRuns); + break; + case "config": + renderAgentConfigTab(tabContent, deps, agent); + break; + case "runs": + renderAgentRunsTab(tabContent, deps, agentRuns); + break; + case "memory": + void renderAgentMemoryTab(tabContent, deps, agent); + break; + } +} + +function renderAgentOverviewTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig, runs: RunLogData[]): void { + // Stats row + const statsRow = container.createDiv({ cls: "af-dash-grid" }); + const totalRuns = runs.length; + const successRuns = runs.filter((r) => r.status === "success").length; + const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0; + const avgTime = totalRuns > 0 ? Math.round(runs.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) : 0; + // Comprehensive: this agent's run logs + its chat/channel usage over the window. + const agentUsage = deps.plugin.runtime.getUsageRecords().filter((u) => u.agent === agent.name); + const { tokens: totalTokens, cost: totalCostAgent } = deps.combinedTotals(runs, agentUsage); + const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : ""; + + deps.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time"); + deps.renderStatCard(statsRow, "Success Rate", `${successRate}%`, "", "check-circle-2", `${successRuns}/${totalRuns}`); + deps.renderStatCard(statsRow, "Avg Time", `${avgTime}s`, "", "clock", "per run"); + deps.renderStatCard(statsRow, "Total Tokens", deps.formatTokenCount(totalTokens), "", "zap", `all time${costSuffixAgent}`); + + // Heartbeat — compact status only (instruction shown in Config tab) + if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) { + const hbSection = container.createDiv({ cls: "af-section-card" }); + const hbHeader = hbSection.createDiv({ cls: "af-section-header" }); + const hbTitle = hbHeader.createDiv({ cls: "af-section-title" }); + createIcon(hbTitle, "heart-pulse"); + hbTitle.appendText(" Heartbeat"); + + const hbActions = hbHeader.createDiv({ cls: "af-detail-header-actions" }); + const hbToggle = hbActions.createDiv({ cls: `af-agent-card-toggle${agent.heartbeatEnabled ? " on" : ""}` }); + hbToggle.onclick = async () => { + const isOn = hbToggle.hasClass("on"); + await deps.plugin.repository.updateHeartbeat(agent.name, { enabled: !isOn }); + await deps.plugin.refreshFromVault(); + new Notice(`Heartbeat ${!isOn ? "enabled" : "paused"} for ${agent.name}`); + }; + + const hbBody = hbSection.createDiv({ cls: "af-config-form" }); + deps.renderConfigRow(hbBody, "Schedule", deps.cronToHuman(agent.heartbeatSchedule)); + const nextRun = deps.plugin.runtime.getNextHeartbeat(agent.name); + if (nextRun && agent.heartbeatEnabled) { + deps.renderConfigRow(hbBody, "Next run", deps.timeUntil(nextRun)); + } + if (agent.heartbeatChannel) { + deps.renderConfigRow(hbBody, "Channel", agent.heartbeatChannel); + } + } + + // Skills + if (agent.skills.length > 0) { + const skillsSection = container.createDiv({ cls: "af-section-card" }); + const skillsHeader = skillsSection.createDiv({ cls: "af-section-header" }); + const skillsTitle = skillsHeader.createDiv({ cls: "af-section-title" }); + createIcon(skillsTitle, "puzzle"); + skillsTitle.appendText(" Skills"); + const skillsBody = skillsSection.createDiv({ cls: "af-detail-skills-list" }); + for (const skill of agent.skills) { + skillsBody.createSpan({ cls: "af-skill-tag", text: skill }); + } + } + + // MCP Servers + const agentMcpServers = agent.mcpServers ?? []; + if (agentMcpServers.length > 0) { + const mcpSection = container.createDiv({ cls: "af-section-card" }); + const mcpHeader = mcpSection.createDiv({ cls: "af-section-header" }); + const mcpTitle = mcpHeader.createDiv({ cls: "af-section-title" }); + createIcon(mcpTitle, "plug"); + mcpTitle.appendText(" MCP Servers"); + const mcpBody = mcpSection.createDiv({ cls: "af-mcp-overview-list" }); + + const cachedServers = deps.plugin.repository.getMcpServers(); + for (const serverName of agentMcpServers) { + const serverInfo = cachedServers.find((s) => s.name === serverName); + const row = mcpBody.createDiv({ cls: "af-mcp-overview-row" }); + const dot = row.createSpan({ + cls: `af-mcp-status-dot ${serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "disconnected"}`, + }); + dot.title = serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "unknown"; + row.createSpan({ cls: "af-mcp-overview-name", text: serverName }); + const toolCount = serverInfo?.toolDetails.length ?? serverInfo?.tools.length ?? 0; + if (toolCount > 0) { + row.createSpan({ cls: "af-mcp-overview-tools", text: `${toolCount} tools` }); + } else if (serverInfo && !serverInfo.enabled) { + row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "disabled" }); + } else if (serverInfo?.status === "needs-auth") { + row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "needs auth" }); + } + } + } + + // Permissions + const hasPermRules = agent.permissionRules.allow.length > 0 || agent.permissionRules.deny.length > 0; + if (hasPermRules || (agent.permissionMode && agent.permissionMode !== "default")) { + const permSection = container.createDiv({ cls: "af-section-card" }); + const permHeader = permSection.createDiv({ cls: "af-section-header" }); + const permTitle = permHeader.createDiv({ cls: "af-section-title" }); + createIcon(permTitle, "shield-check"); + permTitle.appendText(" Permissions"); + const permBody = permSection.createDiv({ cls: "af-config-form" }); + deps.renderConfigRow(permBody, "Mode", agent.permissionMode || "default"); + if (agent.permissionRules.allow.length > 0) { + deps.renderConfigRow(permBody, "Allowed", agent.permissionRules.allow.join(", ")); + } + if (agent.permissionRules.deny.length > 0) { + deps.renderConfigRow(permBody, "Denied", agent.permissionRules.deny.join(", ")); + } + } + + // Recent runs + const runsSection = container.createDiv({ cls: "af-section-card" }); + const runsHeader = runsSection.createDiv({ cls: "af-section-header" }); + const runsTitle = runsHeader.createDiv({ cls: "af-section-title" }); + createIcon(runsTitle, "scroll-text"); + runsTitle.appendText(" Recent Runs"); + const runsBody = runsSection.createDiv({ cls: "af-timeline" }); + + if (runs.length === 0) { + deps.renderEmptyState(runsBody, "scroll-text", "No runs yet", ""); + } else { + for (const run of runs.slice(0, 5)) { + deps.renderTimelineItem(runsBody, run); + } + } +} + +function renderAgentConfigTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig): void { + const form = container.createDiv({ cls: "af-config-form" }); + + deps.renderConfigRow(form, "Name", agent.name); + deps.renderConfigRow(form, "Description", agent.description ?? ""); + deps.renderConfigRow(form, "Model", agent.model); + deps.renderConfigRow(form, "Timeout", `${agent.timeout}s`); + deps.renderConfigRow(form, "Working Directory", agent.cwd ?? "(vault root)"); + deps.renderConfigRow(form, "Permission Mode", agent.permissionMode || "default"); + deps.renderConfigRow(form, "Approval Required", agent.approvalRequired.join(", ") || "none"); + if (agent.permissionRules.allow.length > 0) { + deps.renderConfigRow(form, "Allowed Commands", agent.permissionRules.allow.join(", ")); + } + if (agent.permissionRules.deny.length > 0) { + deps.renderConfigRow(form, "Blocked Commands", agent.permissionRules.deny.join(", ")); + } + deps.renderConfigRow(form, "Memory", agent.memory ? "Enabled" : "Disabled"); + deps.renderConfigRow( + form, + "Auto-compact", + agent.autoCompactThreshold && agent.autoCompactThreshold > 0 + ? `at ${agent.autoCompactThreshold}% context` + : "disabled", + ); + if (agent.wikiReferences && agent.wikiReferences.length > 0) { + deps.renderConfigRow( + form, + "Wiki access", + agent.wikiReferences.map((r) => r.agent).join(", "), + ); + } + deps.renderConfigRow(form, "Tags", agent.tags.join(", ") || "none"); + + const promptSection = form.createDiv({ cls: "af-config-prompt-section" }); + promptSection.createDiv({ cls: "af-slideover-section-title", text: "SYSTEM PROMPT" }); + promptSection.createDiv({ cls: "af-output-block", text: agent.body || "(empty)" }); + + if (agent.heartbeatBody.trim()) { + const hbPromptSection = form.createDiv({ cls: "af-config-prompt-section" }); + hbPromptSection.createDiv({ cls: "af-slideover-section-title", text: "HEARTBEAT INSTRUCTION" }); + hbPromptSection.createDiv({ cls: "af-output-block", text: agent.heartbeatBody }); + } + + const actions = form.createDiv({ cls: "af-slideover-actions" }); + const editBtn = actions.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(editBtn, "edit", "af-btn-icon"); + editBtn.appendText(" Edit Agent"); + editBtn.onclick = () => deps.navigate("edit-agent", agent.name); +} + +function renderAgentRunsTab(container: HTMLElement, deps: AgentDetailPageDeps, runs: RunLogData[]): void { + if (runs.length === 0) { + deps.renderEmptyState(container, "scroll-text", "No runs yet", "Run this agent to see history"); + return; + } + + for (const run of runs) { + const item = container.createDiv({ cls: "af-run-list-item" }); + + const statusIcon = item.createDiv({ cls: `af-tl-icon ${deps.statusToTimelineClass(run.status)}` }); + setIcon(statusIcon, deps.statusToIconName(run.status)); + + const body = item.createDiv({ cls: "af-tl-body" }); + const titleRow = body.createDiv({ cls: "af-tl-title" }); + titleRow.createSpan({ text: run.task }); + titleRow.createSpan({ cls: `af-status-badge ${deps.statusToBadgeClass(run.status)}`, text: deps.statusToBadgeText(run.status) }); + + const meta = body.createDiv({ cls: "af-tl-meta" }); + meta.createSpan({ text: `${deps.formatStarted(run.started)} \u00B7 ${deps.formatDuration(run.durationSeconds)}` }); + if (run.tokensUsed) { + meta.createSpan({ text: `${run.tokensUsed.toLocaleString()} tokens` }); + } + + body.createDiv({ cls: "af-tl-desc", text: truncate(run.output, 120) }); + + item.onclick = () => deps.openSlideover(run); + } +} + +async function renderAgentMemoryTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig): Promise { + if (!agent.memory) { + deps.renderEmptyState(container, "file-text", "Memory disabled", "Enable memory in agent config"); + return; + } + + const wm = await deps.plugin.repository.readWorkingMemory(agent.name); + + // Header: token usage vs budget + reflection status. + const meta = container.createDiv({ cls: "af-form-help" }); + const used = wm?.tokenEstimate ?? 0; + const reflectBits = agent.reflection.enabled + ? `reflection on (${agent.reflection.schedule})` + : "reflection off"; + meta.setText(`~${used} / ${agent.memoryTokenBudget} tokens · ${reflectBits}`); + + if (!wm || wm.sections.length === 0) { + deps.renderEmptyState(container, "file-text", "No memories yet", "Agent will learn from runs"); + } else { + const block = container.createDiv({ cls: "af-output-block" }); + block.setText(renderSections(wm.sections)); + } + + const actions = container.createDiv({ cls: "af-slideover-actions" }); + + const reflectBtn = actions.createEl("button", { cls: "af-btn-sm" }); + createIcon(reflectBtn, "moon", "af-btn-icon"); + reflectBtn.appendText(" Reflect now"); + reflectBtn.onclick = async () => { + reflectBtn.disabled = true; + reflectBtn.setText(" Reflecting…"); + const res = await deps.plugin.runtime.runReflectionNow(agent.name); + new Notice(`Agent Fleet: ${res.message}`); + await renderAgentMemoryTab((container.empty(), container), deps, agent); + }; + + const editBtn = actions.createEl("button", { cls: "af-btn-sm" }); + createIcon(editBtn, "external-link", "af-btn-icon"); + editBtn.appendText(" Open in Editor"); + editBtn.onclick = () => + void deps.plugin.openPath(deps.plugin.repository.getWorkingMemoryPath(agent.name)); +} diff --git a/src/views/pages/agentsPage.ts b/src/views/pages/agentsPage.ts new file mode 100644 index 0000000..639be10 --- /dev/null +++ b/src/views/pages/agentsPage.ts @@ -0,0 +1,191 @@ +import { Notice, setIcon } from "obsidian"; +import type { AgentConfig, AgentHealth, RunLogData, SkillConfig, TaskConfig } from "../../types"; +import { createIcon } from "../../utils/icons"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the agents page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export interface AgentsPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "agent-detail" | "edit-agent", context?: string) => void; + healthToClass: (status: AgentHealth) => string; + /** Lucide icon / emoji / initials avatar renderer (owned by the view). */ + renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void; + /** Value + label stat cell shared with the channel cards (owned by the view). */ + renderAgentStat: (container: HTMLElement, value: string, label: string) => void; + formatTokenCount: (tokens: number) => string; + cronToHuman: (cron: string) => string; + timeUntil: (date: Date) => string; +} + +export function renderAgentsPage(container: HTMLElement, deps: AgentsPageDeps): void { + const page = container.createDiv({ cls: "af-agents-page" }); + const snapshot = deps.plugin.runtime.getSnapshot(); + + const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "Agents" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(newBtn, "plus", "af-btn-icon"); + newBtn.appendText(" New Agent"); + newBtn.onclick = () => void deps.plugin.createAgentTemplate(); + + const grid = page.createDiv({ cls: "af-agents-grid" }); + + if (snapshot.agents.length === 0) { + deps.renderEmptyState(grid, "bot", "No agents configured", "Create your first agent to get started", { + label: "New Agent", + onClick: () => void deps.plugin.createAgentTemplate(), + }); + return; + } + + // Fetch runs once and group by agent — avoids re-filtering the full run + // list for every card (O(agents × runs)). + const runsByAgent = new Map(); + for (const run of deps.plugin.runtime.getRecentRuns()) { + const list = runsByAgent.get(run.agent); + if (list) { + list.push(run); + } else { + runsByAgent.set(run.agent, [run]); + } + } + + for (const agent of snapshot.agents) { + renderAgentCard(grid, deps, agent, snapshot, runsByAgent.get(agent.name) ?? []); + } +} + +function renderAgentCard( + container: HTMLElement, + deps: AgentsPageDeps, + agent: AgentConfig, + snapshot: { tasks: TaskConfig[]; skills: SkillConfig[] }, + agentRuns: RunLogData[], +): void { + const state = deps.plugin.runtime.getAgentState(agent.name); + + const card = container.createDiv({ cls: `af-agent-card${agent.enabled ? "" : " disabled"}` }); + + // Header + const header = card.createDiv({ cls: "af-agent-card-header" }); + const avatarCls = agent.enabled ? deps.healthToClass(state.status) : "disabled"; + const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` }); + deps.renderAgentAvatar(avatar, agent); + + const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" }); + const nameRow = titleBlock.createDiv({ cls: "af-agent-card-name" }); + nameRow.appendText(agent.name); + if (agent.heartbeatEnabled && agent.heartbeatSchedule) { + const hbIcon = nameRow.createSpan({ cls: "af-heartbeat-indicator" }); + setIcon(hbIcon, "heart-pulse"); + hbIcon.title = `Heartbeat: ${agent.heartbeatSchedule}`; + } + titleBlock.createDiv({ cls: "af-agent-card-desc", text: agent.description ?? "No description" }); + + const toggle = header.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` }); + toggle.onclick = (e) => { + e.stopPropagation(); + void deps.plugin.toggleAgent(agent.name, !agent.enabled); + }; + + // Stats + const stats = card.createDiv({ cls: "af-agent-card-stats" }); + const totalRuns = agentRuns.length; + const successRuns = agentRuns.filter((r) => r.status === "success").length; + const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0; + const avgTime = + totalRuns > 0 + ? Math.round(agentRuns.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) + : 0; + const totalTokens = agentRuns.reduce((s, r) => s + (r.tokensUsed ?? 0), 0); + + deps.renderAgentStat(stats, String(totalRuns), "Runs"); + deps.renderAgentStat(stats, `${successRate}%`, "Success"); + deps.renderAgentStat(stats, `${avgTime}s`, "Avg Time"); + deps.renderAgentStat(stats, deps.formatTokenCount(totalTokens), "Tokens"); + + // Skills + if (agent.skills.length > 0) { + const skillsRow = card.createDiv({ cls: "af-agent-card-skills" }); + for (const skill of agent.skills) { + skillsRow.createSpan({ cls: "af-skill-tag", text: skill }); + } + } + + // Heartbeat status — same gate + next-run source as the agent detail + // Overview tab, with the same quick toggle (repository.updateHeartbeat). + if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) { + const hbRow = card.createDiv({ cls: "af-agent-card-heartbeat" }); + const hbRowIcon = hbRow.createSpan({ cls: "af-agent-card-hb-icon" }); + setIcon(hbRowIcon, "heart-pulse"); + + const parts: string[] = [deps.cronToHuman(agent.heartbeatSchedule)]; + if (agent.heartbeatEnabled) { + const nextHb = deps.plugin.runtime.getNextHeartbeat(agent.name); + if (nextHb) parts.push(`next ${deps.timeUntil(nextHb)}`); + } else { + parts.push("paused"); + } + hbRow.createSpan({ + cls: "af-agent-card-hb-text", + text: `Heartbeat · ${parts.join(" · ")}`, + }); + + const hbToggle = hbRow.createDiv({ + cls: `af-agent-card-toggle af-agent-card-toggle-sm${agent.heartbeatEnabled ? " on" : ""}`, + }); + hbToggle.title = agent.heartbeatEnabled ? "Pause heartbeat" : "Enable heartbeat"; + hbToggle.onclick = (e) => { + e.stopPropagation(); + void (async () => { + await deps.plugin.repository.updateHeartbeat(agent.name, { enabled: !agent.heartbeatEnabled }); + await deps.plugin.refreshFromVault(); + new Notice(`Heartbeat ${!agent.heartbeatEnabled ? "enabled" : "paused"} for ${agent.name}`); + })(); + }; + } + + // Footer + const footer = card.createDiv({ cls: "af-agent-card-footer" }); + const metaParts: string[] = [`Model: ${agent.model}`]; + if (agent.approvalRequired.length > 0) { + metaParts.push(`Approval: ${agent.approvalRequired.join(", ")}`); + } + if (agent.memory) metaParts.push("Memory: on"); + if (!agent.enabled) metaParts.unshift("Disabled"); + footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") }); + + const actions = footer.createDiv({ cls: "af-agent-card-actions" }); + + if (!agent.enabled) { + const enableBtn = actions.createEl("button", { cls: "af-btn-sm", text: "Enable" }); + enableBtn.onclick = (e) => { + e.stopPropagation(); + void deps.plugin.toggleAgent(agent.name, true); + }; + } + + const editBtn = actions.createEl("button", { cls: "af-btn-sm" }); + createIcon(editBtn, "edit", "af-btn-icon"); + editBtn.appendText(" Edit"); + editBtn.onclick = (e) => { + e.stopPropagation(); + deps.navigate("edit-agent", agent.name); + }; + + if (agent.enabled) { + const runBtn = actions.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(runBtn, "play", "af-btn-icon"); + runBtn.appendText(" Run"); + runBtn.onclick = (e) => { + e.stopPropagation(); + void deps.plugin.runAgentPrompt(agent.name); + }; + } + + card.onclick = () => deps.navigate("agent-detail", agent.name); +} diff --git a/src/views/pages/approvalsPage.ts b/src/views/pages/approvalsPage.ts new file mode 100644 index 0000000..18a1ab9 --- /dev/null +++ b/src/views/pages/approvalsPage.ts @@ -0,0 +1,114 @@ +import { setIcon } from "obsidian"; +import type { RunLogData } from "../../types"; +import { createIcon } from "../../utils/icons"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the approvals page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export interface ApprovalsPageDeps extends DashboardPageDeps { + formatStarted: (iso: string) => string; + /** Re-render the whole dashboard view (after an approval is resolved). */ + rerender: () => Promise; +} + +export function renderApprovalsPage(container: HTMLElement, deps: ApprovalsPageDeps): void { + const page = container.createDiv({ cls: "af-approvals-page" }); + const runs = deps.plugin.runtime.getRecentRuns(); + + const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "Approvals" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + // Pending approvals + const pendingRuns = runs.filter((r) => + (r.approvals ?? []).some((a) => a.status === "pending"), + ); + + if (pendingRuns.length > 0) { + const pendingSection = page.createDiv({ cls: "af-section-card" }); + const pendingHeader = pendingSection.createDiv({ cls: "af-section-header" }); + const pendingTitle = pendingHeader.createDiv({ cls: "af-section-title" }); + createIcon(pendingTitle, "alert-triangle"); + pendingTitle.appendText(` Pending (${pendingRuns.length})`); + + const pendingBody = pendingSection.createDiv({ cls: "af-approvals-list" }); + for (const run of pendingRuns) { + renderApprovalItem(pendingBody, deps, run, true); + } + } else { + const emptySection = page.createDiv({ cls: "af-section-card" }); + deps.renderEmptyState(emptySection, "shield-check", "No pending approvals", "All clear!"); + } + + // Resolved approvals history + const resolvedRuns = runs.filter((r) => + (r.approvals ?? []).some((a) => a.status !== "pending"), + ); + + if (resolvedRuns.length > 0) { + const resolvedSection = page.createDiv({ cls: "af-section-card" }); + const resolvedHeader = resolvedSection.createDiv({ cls: "af-section-header" }); + const resolvedTitle = resolvedHeader.createDiv({ cls: "af-section-title" }); + createIcon(resolvedTitle, "check-circle-2"); + resolvedTitle.appendText(" History"); + + const resolvedBody = resolvedSection.createDiv({ cls: "af-approvals-list" }); + for (const run of resolvedRuns.slice(0, 20)) { + renderApprovalItem(resolvedBody, deps, run, false); + } + } +} + +function renderApprovalItem(container: HTMLElement, deps: ApprovalsPageDeps, run: RunLogData, showActions: boolean): void { + for (const approval of run.approvals ?? []) { + if (showActions && approval.status !== "pending") continue; + if (!showActions && approval.status === "pending") continue; + + const item = container.createDiv({ cls: "af-approval-item" }); + + const iconEl = item.createDiv({ cls: "af-approval-item-icon" }); + if (approval.status === "pending") { + setIcon(iconEl, "shield-check"); + iconEl.addClass("pending"); + } else if (approval.status === "approved") { + setIcon(iconEl, "check-circle-2"); + iconEl.addClass("approved"); + } else { + setIcon(iconEl, "x-circle"); + iconEl.addClass("rejected"); + } + + const body = item.createDiv({ cls: "af-approval-item-body" }); + body.createDiv({ + cls: "af-approval-item-title", + text: `${run.agent} \u2192 ${approval.tool}`, + }); + body.createDiv({ + cls: "af-approval-item-meta", + text: `Task: ${run.task} \u00B7 ${approval.command ?? "no command"} \u00B7 ${deps.formatStarted(run.started)}`, + }); + if (approval.reason) { + body.createDiv({ cls: "af-approval-item-reason", text: `Reason: ${approval.reason}` }); + } + + if (showActions && approval.status === "pending") { + const actions = item.createDiv({ cls: "af-approval-item-actions" }); + const approveBtn = actions.createEl("button", { cls: "af-btn-approve" }); + createIcon(approveBtn, "check-circle-2", "af-btn-icon"); + approveBtn.appendText(" Approve"); + approveBtn.onclick = () => + void deps.plugin.runtime + .resolveApproval(run, approval.tool, "approved") + .then(() => deps.rerender()); + + const rejectBtn = actions.createEl("button", { cls: "af-btn-reject" }); + createIcon(rejectBtn, "x-circle", "af-btn-icon"); + rejectBtn.appendText(" Reject"); + rejectBtn.onclick = () => + void deps.plugin.runtime + .resolveApproval(run, approval.tool, "rejected") + .then(() => deps.rerender()); + } + } +} diff --git a/src/views/pages/channelsPage.ts b/src/views/pages/channelsPage.ts new file mode 100644 index 0000000..facfdc0 --- /dev/null +++ b/src/views/pages/channelsPage.ts @@ -0,0 +1,146 @@ +import { setIcon } from "obsidian"; +import type { ChannelConfig } from "../../types"; +import { createIcon } from "../../utils/icons"; +import { channelStatusToAvatarClass } from "../forms/channelForm"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the channels page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export interface ChannelsPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "create-channel" | "edit-channel", context?: string) => void; + /** Value + label stat cell shared with the agent cards (owned by the view). */ + renderAgentStat: (container: HTMLElement, value: string, label: string) => void; +} + +export function renderChannelsPage(container: HTMLElement, deps: ChannelsPageDeps): void { + const page = container.createDiv({ cls: "af-agents-page" }); + const snapshot = deps.plugin.runtime.getSnapshot(); + + const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "Channels" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(newBtn, "plus", "af-btn-icon"); + newBtn.appendText(" New Channel"); + newBtn.onclick = () => deps.navigate("create-channel"); + + const grid = page.createDiv({ cls: "af-agents-grid" }); + + if (snapshot.channels.length === 0) { + deps.renderEmptyState( + grid, + "radio", + "No channels configured", + "Connect an agent to Slack or another chat platform", + { label: "New Channel", onClick: () => deps.navigate("create-channel") }, + ); + return; + } + + for (const channel of snapshot.channels) { + renderChannelCard(grid, deps, channel, snapshot.validationIssues); + } +} + +function renderChannelCard( + container: HTMLElement, + deps: ChannelsPageDeps, + channel: ChannelConfig, + validationIssues: Array<{ path: string; message: string }>, +): void { + const status = deps.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled"; + const avatarCls = channelStatusToAvatarClass(status); + const cardCls = channel.enabled && status !== "disabled" ? "af-agent-card" : "af-agent-card disabled"; + + const card = container.createDiv({ cls: cardCls }); + card.setCssStyles({ cursor: "default" }); // No card-level click — channels have no detail page + + // Header — avatar (icon + status color) + name/agent + type pill + const header = card.createDiv({ cls: "af-agent-card-header" }); + const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` }); + setIcon(avatar, "radio"); + + const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" }); + titleBlock.createDiv({ cls: "af-agent-card-name", text: channel.name }); + titleBlock.createDiv({ + cls: "af-agent-card-desc", + text: `Default: ${channel.defaultAgent}`, + }); + + const statusPill = header.createSpan({ cls: `af-pill ${channelStatusPillColor(status)}` }); + statusPill.createSpan({ cls: "af-dot" }); + statusPill.appendText(` ${status}`); + + // Agents — show all allowed agents as tags (same style as skill tags on agent cards) + if (channel.allowedAgents.length > 0) { + const agentsRow = card.createDiv({ cls: "af-agent-card-skills" }); + for (const name of channel.allowedAgents) { + const tag = agentsRow.createSpan({ cls: "af-skill-tag", text: name }); + if (name === channel.defaultAgent) { + tag.setCssStyles({ fontWeight: "700" }); + } + } + } + + // Stats — sessions, messages in/out, allowlist + const stats = card.createDiv({ cls: "af-agent-card-stats" }); + const sessionCount = deps.plugin.channelManager?.getSessionCount(channel.name) ?? 0; + const metrics = deps.plugin.channelManager?.getMetrics(channel.name); + const agentCount = channel.allowedAgents.length > 0 + ? String(channel.allowedAgents.length) + : "all"; + + deps.renderAgentStat(stats, agentCount, "Agents"); + deps.renderAgentStat(stats, String(sessionCount), "Sessions"); + deps.renderAgentStat(stats, String(metrics?.messagesReceived ?? 0), "In"); + deps.renderAgentStat(stats, String(metrics?.messagesSent ?? 0), "Out"); + + // Footer — meta text + edit action + const footer = card.createDiv({ cls: "af-agent-card-footer" }); + const metaParts: string[] = [channel.type]; + if (!channel.enabled) metaParts.push("disabled"); + if (channel.allowedUsers.length > 0) metaParts.push(`${channel.allowedUsers.length} user(s)`); + else metaParts.push("allowlist empty"); + footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") }); + + const actions = footer.createDiv({ cls: "af-agent-card-actions" }); + const editBtn = actions.createEl("button", { cls: "af-btn-sm" }); + createIcon(editBtn, "edit", "af-btn-icon"); + editBtn.appendText(" Edit"); + editBtn.onclick = (e) => { + e.stopPropagation(); + deps.navigate("edit-channel", channel.name); + }; + + // Validation issues — red-tinted block below the footer + const issues = validationIssues.filter((i) => i.path === channel.filePath); + if (issues.length > 0) { + const issuesBox = card.createDiv({ cls: "af-channel-issues" }); + for (const issue of issues) { + issuesBox.createDiv({ cls: "af-channel-issue-row", text: issue.message }); + } + } + + // No card-level click — channels have no detail page. Editing is via the edit button. +} + +/** Map a channel status to an `af-pill` color variant for the header badge. */ +function channelStatusPillColor(status: string): string { + switch (status) { + case "connected": + return "green"; + case "connecting": + case "reconnecting": + return "blue"; + case "needs-auth": + case "error": + return "red"; + case "stopped": + case "disabled": + default: + return ""; + } +} diff --git a/src/views/pages/mcpPage.ts b/src/views/pages/mcpPage.ts new file mode 100644 index 0000000..ea887a7 --- /dev/null +++ b/src/views/pages/mcpPage.ts @@ -0,0 +1,358 @@ +import { Notice, setIcon } from "obsidian"; +import type { McpServer, McpTool } from "../../types"; +import { truncate } from "../../utils/markdown"; +import { splitLines } from "../../utils/platform"; +import { createIcon } from "../../utils/icons"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the MCP servers page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. The probe cache and in-flight auth set are view state — the page + * receives the live references so results survive re-renders. */ +export interface McpPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "add-mcp-server") => void; + /** Label + monospace value row used by the detail slideover (owned by the view). */ + renderDetailRow: (container: HTMLElement, label: string, value: string) => void; + /** Re-render the whole dashboard view. */ + rerender: () => Promise; + /** View content element the detail slideover overlay attaches to. */ + contentEl: HTMLElement; + /** Transient per-server tool probe results (name → tools), owned by the view. */ + mcpProbeCache: Map; + /** Names of servers with an OAuth flow in flight, owned by the view. */ + authenticatingServers: Set; +} + +export function renderMcpPage(container: HTMLElement, deps: McpPageDeps): void { + const page = container.createDiv({ cls: "af-agents-page" }); + + const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "MCP Servers" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + const addBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(addBtn, "plus", "af-btn-icon"); + addBtn.appendText(" Add Server"); + addBtn.onclick = () => deps.navigate("add-mcp-server"); + + const servers = deps.plugin.repository.getMcpServers(); + + if (servers.length === 0) { + deps.renderEmptyState(page, "plug", "No MCP servers registered", "Click 'Add Server' above to register one."); + return; + } + + const grid = page.createDiv({ cls: "af-agents-grid" }); + for (const server of servers) { + renderMcpCard(grid, deps, server); + } +} + +/** Whether the server has a stored auth token (OAuth/static bearer). */ +function mcpHasToken(deps: McpPageDeps, server: McpServer): boolean { + return deps.plugin.mcpAuth.hasToken(server.name); +} + +/** Whether an http/sse server still needs the user to authenticate (auth is + * oauth/bearer but no token is stored yet). */ +function mcpNeedsAuth(deps: McpPageDeps, server: McpServer): boolean { + return ( + server.type !== "stdio" && + (server.auth === "oauth" || server.auth === "bearer") && + !mcpHasToken(deps, server) + ); +} + +function renderMcpCard(container: HTMLElement, deps: McpPageDeps, server: McpServer): void { + const card = container.createDiv({ cls: `af-mcp-card${server.enabled ? "" : " af-mcp-card-disabled"}` }); + const needsAuth = mcpNeedsAuth(deps, server); + + const header = card.createDiv({ cls: "af-agent-card-header" }); + const statusClass = !server.enabled ? "disabled" : needsAuth ? "pending" : "idle"; + const avatarEl = header.createDiv({ cls: `af-agent-card-avatar ${statusClass}` }); + setIcon(avatarEl, "plug"); + + const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" }); + titleBlock.createDiv({ cls: "af-agent-card-name", text: server.name }); + + const metaRow = titleBlock.createDiv({ cls: "af-agent-card-desc af-mcp-meta" }); + metaRow.createSpan({ cls: "af-mcp-type-badge", text: server.type }); + if (server.source === "imported") { + metaRow.createSpan({ cls: "af-badge", text: "imported" }); + } + + // Enable/disable toggle — writes the registry file frontmatter. + const toggle = header.createDiv({ cls: `af-agent-card-toggle${server.enabled ? " on" : ""}` }); + toggle.onclick = (e) => { + e.stopPropagation(); + void deps.plugin.repository.setMcpServerEnabled(server.name, !server.enabled).then(async () => { + await deps.plugin.refreshFromVault(); + void deps.rerender(); + }); + }; + + // Status badge + const statusBadge = card.createDiv({ cls: `af-mcp-status-badge ${!server.enabled ? "disabled" : needsAuth ? "needs-auth" : "connected"}` }); + const statusIcon = statusBadge.createSpan(); + if (!server.enabled) { + setIcon(statusIcon, "pause"); + statusBadge.createSpan({ text: " Disabled" }); + } else if (needsAuth) { + setIcon(statusIcon, "alert-circle"); + statusBadge.createSpan({ text: " Needs auth" }); + } else { + setIcon(statusIcon, "check-circle"); + statusBadge.createSpan({ text: server.type === "stdio" ? " Enabled" : " Authenticated" }); + } + + if (server.description) { + const desc = truncateDescription(server.description, 120); + card.createDiv({ cls: "af-mcp-description", text: desc }); + } + + const urlOrCmd = server.url ?? server.command ?? ""; + if (urlOrCmd) { + card.createDiv({ cls: "af-mcp-command", text: truncate(urlOrCmd, 60) }); + } + + // Tool count from the on-demand probe cache (if probed this session). + const probed = deps.mcpProbeCache.get(server.name); + if (probed && probed.length > 0) { + const toolFooter = card.createDiv({ cls: "af-mcp-tool-footer" }); + const toolCount = toolFooter.createDiv({ cls: "af-mcp-tool-count" }); + const toolIcon = toolCount.createSpan(); + setIcon(toolIcon, "wrench"); + toolCount.createSpan({ text: ` ${probed.length} tools` }); + const chips = toolFooter.createDiv({ cls: "af-mcp-tool-chips" }); + for (const t of probed.slice(0, 4)) { + chips.createSpan({ cls: "af-mcp-tool-chip", text: t.name }); + } + if (probed.length > 4) { + chips.createSpan({ cls: "af-mcp-tool-chip af-mcp-tool-chip-more", text: `+${probed.length - 4}` }); + } + } + + // Auth / authenticating indicator for http/sse servers. + if (deps.authenticatingServers.has(server.name)) { + const authRow = card.createDiv({ cls: "af-mcp-auth-row" }); + const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary", attr: { disabled: "true" } }); + const spinIcon = authBtn.createSpan({ cls: "af-spin" }); + setIcon(spinIcon, "loader-2"); + authBtn.appendText(" Authenticating…"); + } else if (server.enabled && needsAuth && server.auth === "oauth") { + const authRow = card.createDiv({ cls: "af-mcp-auth-row" }); + const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary" }); + const authIcon = authBtn.createSpan(); + setIcon(authIcon, "key"); + authBtn.appendText(" Authenticate"); + authBtn.onclick = (e) => { + e.stopPropagation(); + void authenticateMcpServer(deps, server); + }; + } + + card.onclick = () => openMcpDetailSlideover(deps, server); +} + +async function authenticateMcpServer(deps: McpPageDeps, server: McpServer): Promise { + if (!server.url) { + new Notice("No URL found for this server — can't authenticate."); + return; + } + + deps.authenticatingServers.add(server.name); + void deps.rerender(); + + new Notice(`Authenticating ${server.name}… Complete authorization in your browser.`, 10000); + + try { + const transport = server.type === "sse" ? "sse" : "http"; + await deps.plugin.mcpManager.authenticateServer(server.name, server.url, transport); + new Notice(`${server.name} authenticated successfully!`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Authentication failed: ${msg}`, 8000); + } finally { + deps.authenticatingServers.delete(server.name); + void deps.rerender(); + } +} + +/** On-demand tool probe for the MCP detail view. Stores results in the + * transient cache and re-renders. */ +async function probeMcpServer(deps: McpPageDeps, server: McpServer): Promise { + try { + const tools = await deps.plugin.mcpManager.probeServer(server); + deps.mcpProbeCache.set(server.name, tools); + if (tools.length === 0) new Notice(`No tools discovered for ${server.name}.`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Probe failed: ${truncate(msg, 150)}`); + } +} + +function truncateDescription(text: string, maxLen: number): string { + // Take first sentence or first line, whichever is shorter + const firstLine = splitLines(text)[0] ?? text; + const firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine; + const candidate = firstSentence.length < firstLine.length ? firstSentence : firstLine; + if (candidate.length <= maxLen) return candidate; + return candidate.slice(0, maxLen - 1) + "…"; +} + +function openMcpDetailSlideover(deps: McpPageDeps, server: McpServer): void { + deps.contentEl.querySelector(".af-slideover-overlay")?.remove(); + + const overlay = deps.contentEl.createDiv({ cls: "af-slideover-overlay" }); + const panel = overlay.createDiv({ cls: "af-slideover" }); + + const header = panel.createDiv({ cls: "af-slideover-header" }); + header.createDiv({ cls: "af-slideover-title", text: server.name }); + const closeBtn = header.createEl("button", { cls: "clickable-icon" }); + setIcon(closeBtn, "cross"); + closeBtn.onclick = () => overlay.remove(); + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const body = panel.createDiv({ cls: "af-slideover-body" }); + + // Description + if (server.description) { + const descSection = body.createDiv({ cls: "af-slideover-section" }); + descSection.createDiv({ cls: "af-slideover-section-title", text: "DESCRIPTION" }); + descSection.createDiv({ cls: "af-mcp-detail-description", text: server.description }); + } + + // Server info + const infoSection = body.createDiv({ cls: "af-slideover-section" }); + infoSection.createDiv({ cls: "af-slideover-section-title", text: "SERVER INFO" }); + deps.renderDetailRow(infoSection, "Name", server.name); + deps.renderDetailRow(infoSection, "Transport", server.type); + deps.renderDetailRow(infoSection, "Enabled", server.enabled ? "yes" : "no"); + if (server.type !== "stdio") { + deps.renderDetailRow(infoSection, "Auth", server.auth ?? "none"); + deps.renderDetailRow(infoSection, "Authenticated", mcpHasToken(deps, server) ? "yes" : "no"); + } + if (server.source) deps.renderDetailRow(infoSection, "Source", server.source); + if (server.url) deps.renderDetailRow(infoSection, "URL", server.url); + if (server.command) deps.renderDetailRow(infoSection, "Command", server.command); + if (server.args && server.args.length > 0) deps.renderDetailRow(infoSection, "Args", server.args.join(" ")); + + // Tools — populated by the on-demand probe (registry definitions carry no + // probed tools until the user clicks "Probe tools"). + const probedTools = deps.mcpProbeCache.get(server.name) ?? []; + const toolsSection = body.createDiv({ cls: "af-slideover-section" }); + const toolsTitleRow = toolsSection.createDiv({ cls: "af-slideover-section-title" }); + toolsTitleRow.setText(`TOOLS (${probedTools.length})`); + const probeBtn = toolsSection.createEl("button", { cls: "af-btn-sm" }); + const probeIcon = probeBtn.createSpan(); + setIcon(probeIcon, "wrench"); + probeBtn.appendText(" Probe tools"); + probeBtn.onclick = async () => { + probeBtn.disabled = true; + probeBtn.setText(" Probing…"); + await probeMcpServer(deps, server); + overlay.remove(); + openMcpDetailSlideover(deps, server); + }; + + if (probedTools.length > 0) { + for (const tool of probedTools) { + const toolItem = toolsSection.createDiv({ cls: "af-mcp-tool-detail" }); + const toolHeader = toolItem.createDiv({ cls: "af-mcp-tool-detail-header" }); + const toolNameEl = toolHeader.createSpan({ cls: "af-mcp-tool-detail-name" }); + const toolNameIcon = toolNameEl.createSpan(); + setIcon(toolNameIcon, "wrench"); + toolNameEl.createSpan({ text: ` ${tool.name}` }); + + if (tool.inputSchema) { + const params = (tool.inputSchema as { required?: string[] }).required ?? []; + if (params.length > 0) { + toolHeader.createSpan({ + cls: "af-mcp-tool-param-count", + text: `${params.length} param${params.length !== 1 ? "s" : ""}`, + }); + } + } + + if (tool.description) { + // Show first 2 lines of description, rest in collapsible + const descLines = splitLines(tool.description).filter((l) => l.trim()); + const shortDesc = descLines.slice(0, 2).join(" ").trim(); + const hasMore = descLines.length > 2; + + if (hasMore) { + const details = toolItem.createEl("details", { cls: "af-mcp-tool-detail-desc" }); + details.createEl("summary", { text: truncateDescription(shortDesc, 200) }); + details.createDiv({ cls: "af-mcp-tool-detail-full", text: tool.description }); + } else { + toolItem.createDiv({ cls: "af-mcp-tool-detail-desc", text: shortDesc }); + } + } + + // Input schema params + if (tool.inputSchema) { + const props = (tool.inputSchema as { properties?: Record }).properties; + const required = new Set((tool.inputSchema as { required?: string[] }).required ?? []); + if (props && Object.keys(props).length > 0) { + const paramsEl = toolItem.createDiv({ cls: "af-mcp-tool-params" }); + for (const [paramName, paramDef] of Object.entries(props)) { + const paramEl = paramsEl.createDiv({ cls: "af-mcp-tool-param" }); + paramEl.createSpan({ cls: "af-mcp-tool-param-name", text: paramName }); + if (paramDef.type) { + paramEl.createSpan({ cls: "af-mcp-tool-param-type", text: paramDef.type }); + } + if (required.has(paramName)) { + paramEl.createSpan({ cls: "af-mcp-tool-param-required", text: "required" }); + } + if (paramDef.description) { + paramEl.createSpan({ + cls: "af-mcp-tool-param-desc", + text: truncate(paramDef.description, 80), + }); + } + } + } + } + } + } else { + toolsSection.createDiv({ + cls: "af-form-hint", + text: "Click \"Probe tools\" to discover the tools this server exposes.", + }); + } + + // Actions section + const actionsSection = body.createDiv({ cls: "af-slideover-section" }); + actionsSection.createDiv({ cls: "af-slideover-section-title", text: "ACTIONS" }); + + if (server.enabled && server.url && server.auth === "oauth" && !mcpHasToken(deps, server)) { + const authBtn = actionsSection.createEl("button", { cls: "af-btn-sm primary" }); + const authIcon = authBtn.createSpan(); + setIcon(authIcon, "key"); + authBtn.appendText(" Authenticate"); + authBtn.onclick = () => { + overlay.remove(); + void authenticateMcpServer(deps, server); + }; + } + + const removeBtn = actionsSection.createEl("button", { cls: "af-btn-sm danger" }); + const removeIcon = removeBtn.createSpan(); + setIcon(removeIcon, "trash-2"); + removeBtn.appendText(" Remove Server"); + removeBtn.onclick = async () => { + try { + await deps.plugin.repository.deleteMcpServer(server.name); + deps.plugin.mcpAuth.removeToken(server.name); + deps.mcpProbeCache.delete(server.name); + new Notice(`Server "${server.name}" removed.`); + overlay.remove(); + await deps.plugin.refreshFromVault(); + void deps.rerender(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to remove server: ${msg}`); + } + }; +} diff --git a/src/views/pages/runsPage.ts b/src/views/pages/runsPage.ts new file mode 100644 index 0000000..2427ec2 --- /dev/null +++ b/src/views/pages/runsPage.ts @@ -0,0 +1,77 @@ +import { setIcon } from "obsidian"; +import type { RunLogData } from "../../types"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the run-history page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export interface RunsPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "agent-detail", context?: string) => void; + /** Open the run-details slideover (owned by the view). */ + openSlideover: (run: RunLogData) => void; + formatStarted: (iso: string) => string; + formatDuration: (seconds: number) => string; + statusToBadgeClass: (status: string) => string; + statusToIconName: (status: string) => string; + statusToBadgeText: (status: string) => string; +} + +export function renderRunsPage(container: HTMLElement, deps: RunsPageDeps): void { + const page = container.createDiv({ cls: "af-runs-page" }); + const runs = deps.plugin.runtime.getRecentRuns(); + + const toolbar = page.createDiv({ cls: "af-runs-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "Run History" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + const tableWrap = page.createDiv({ cls: "af-runs-table" }); + + if (runs.length === 0) { + deps.renderEmptyState(tableWrap, "scroll-text", "No runs yet", "Run an agent to see history here"); + return; + } + + const table = tableWrap.createEl("table"); + const thead = table.createEl("thead"); + const headerRow = thead.createEl("tr"); + for (const col of ["Status", "Agent", "Task", "Started", "Duration", "Tokens", "Model"]) { + headerRow.createEl("th", { text: col }); + } + + const tbody = table.createEl("tbody"); + for (const run of runs.slice(0, 50)) { + renderRunRow(tbody, deps, run); + } +} + +function renderRunRow(tbody: HTMLElement, deps: RunsPageDeps, run: RunLogData): void { + const row = tbody.createEl("tr"); + + const statusTd = row.createEl("td"); + const badge = statusTd.createSpan({ + cls: `af-status-badge ${deps.statusToBadgeClass(run.status)}`, + }); + const badgeIcon = badge.createSpan(); + setIcon(badgeIcon, deps.statusToIconName(run.status)); + badge.appendText(` ${deps.statusToBadgeText(run.status)}`); + + const agentTd = row.createEl("td", { cls: "af-agent-link" }); + agentTd.setText(run.agent); + agentTd.onclick = (e) => { + e.stopPropagation(); + deps.navigate("agent-detail", run.agent); + }; + + row.createEl("td", { text: run.task }); + row.createEl("td", { cls: "af-mono", text: deps.formatStarted(run.started) }); + row.createEl("td", { cls: "af-mono", text: deps.formatDuration(run.durationSeconds) }); + row.createEl("td", { + cls: "af-mono", + text: run.tokensUsed ? run.tokensUsed.toLocaleString() : "\u2014", + }); + row.createEl("td", { cls: "af-mono", text: run.model }); + + row.setCssStyles({ cursor: "pointer" }); + row.onclick = () => deps.openSlideover(run); +} diff --git a/src/views/pages/shared.ts b/src/views/pages/shared.ts new file mode 100644 index 0000000..06fe06d --- /dev/null +++ b/src/views/pages/shared.ts @@ -0,0 +1,21 @@ +import type AgentFleetPlugin from "../../main"; + +/** + * View helpers every extracted page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. Each page module extends this with its own (narrowly typed) + * `navigate` delegate and any page-specific extras. Shared state (ticker + * registries, detail context, probe caches) stays on the view and is handed + * to pages through these deps or as parameters. + */ +export interface DashboardPageDeps { + plugin: AgentFleetPlugin; + /** Standard icon + label + optional CTA empty-state block (owned by the view). */ + renderEmptyState: ( + container: HTMLElement, + iconName: string, + label: string, + sublabel: string, + action?: { label: string; onClick: () => void }, + ) => void; +} diff --git a/src/views/pages/skillsPage.ts b/src/views/pages/skillsPage.ts new file mode 100644 index 0000000..f49cd8b --- /dev/null +++ b/src/views/pages/skillsPage.ts @@ -0,0 +1,76 @@ +import { setIcon } from "obsidian"; +import type { AgentConfig, SkillConfig } from "../../types"; +import { createIcon } from "../../utils/icons"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the skills-library page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export interface SkillsPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "edit-skill", context?: string) => void; +} + +export function renderSkillsPage(container: HTMLElement, deps: SkillsPageDeps): void { + const page = container.createDiv({ cls: "af-skills-page" }); + const snapshot = deps.plugin.runtime.getSnapshot(); + + const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "Skills Library" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(newBtn, "plus", "af-btn-icon"); + newBtn.appendText(" New Skill"); + newBtn.onclick = () => void deps.plugin.createSkillTemplate(); + + const grid = page.createDiv({ cls: "af-skills-grid" }); + + if (snapshot.skills.length === 0) { + deps.renderEmptyState(grid, "puzzle", "No skills yet", "Create skills to give agents specialized abilities", { + label: "New Skill", + onClick: () => void deps.plugin.createSkillTemplate(), + }); + return; + } + + for (const skill of snapshot.skills) { + renderSkillCard(grid, deps, skill, snapshot.agents); + } +} + +function renderSkillCard(container: HTMLElement, deps: SkillsPageDeps, skill: SkillConfig, agents: AgentConfig[]): void { + const card = container.createDiv({ cls: "af-skill-card" }); + + const cardHeader = card.createDiv({ cls: "af-skill-card-header" }); + const iconEl = cardHeader.createDiv({ cls: "af-skill-card-icon" }); + setIcon(iconEl, getSkillIcon(skill.name)); + + const skillEditBtn = cardHeader.createEl("button", { cls: "af-btn-sm af-btn-xs" }); + createIcon(skillEditBtn, "edit", "af-btn-icon"); + skillEditBtn.onclick = (e) => { + e.stopPropagation(); + deps.navigate("edit-skill", skill.name); + }; + + card.createDiv({ cls: "af-skill-card-name", text: skill.name }); + card.createDiv({ cls: "af-skill-card-desc", text: skill.description ?? "No description" }); + + const usedBy = agents.filter((a) => a.skills.includes(skill.name)); + if (usedBy.length > 0) { + const agentsRow = card.createDiv({ cls: "af-skill-card-agents" }); + for (const agent of usedBy) { + agentsRow.createSpan({ cls: "af-skill-card-agent-tag", text: agent.name }); + } + } + + // No card-level click — skills have no detail page. Editing is via the edit button. +} + +function getSkillIcon(name: string): string { + if (name.includes("git")) return "settings"; + if (name.includes("summarize") || name.includes("log")) return "activity"; + if (name.includes("review") || name.includes("check")) return "check-circle-2"; + if (name.includes("vault") || name.includes("note")) return "file-text"; + return "puzzle"; +} diff --git a/src/views/pages/taskDetailPage.ts b/src/views/pages/taskDetailPage.ts new file mode 100644 index 0000000..314570c --- /dev/null +++ b/src/views/pages/taskDetailPage.ts @@ -0,0 +1,111 @@ +import { setIcon } from "obsidian"; +import type { RunLogData } from "../../types"; +import { createIcon } from "../../utils/icons"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the task detail page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export interface TaskDetailPageDeps extends DashboardPageDeps { + /** Navigate the dashboard to another page. */ + navigate: (page: "edit-task", context?: string) => void; + /** Label + monospace value config row (owned by the view). */ + renderConfigRow: (container: HTMLElement, label: string, value: string) => void; + /** Run timeline entry shared with the overview page (owned by the view). */ + renderTimelineItem: (container: HTMLElement, run: RunLogData) => void; + humanizeCron: (cron: string) => string; + formatStarted: (iso: string) => string; +} + +export function renderTaskDetailPage(container: HTMLElement, deps: TaskDetailPageDeps, taskId: string | undefined): void { + const page = container.createDiv({ cls: "af-task-detail-page" }); + if (!taskId) { + deps.renderEmptyState(page, "circle-dot", "No task selected", ""); + return; + } + + const task = deps.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId); + if (!task) { + deps.renderEmptyState(page, "circle-dot", "Task not found", `Task "${taskId}" was not found`); + return; + } + + const snapshot = deps.plugin.runtime.getSnapshot(); + const runs = deps.plugin.runtime.getRecentRuns().filter((r) => r.task === taskId); + const agent = snapshot.agents.find((a) => a.name === task.agent); + + // Header + const header = page.createDiv({ cls: "af-detail-header" }); + const headerLeft = header.createDiv({ cls: "af-detail-header-left" }); + const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" }); + setIcon(taskIcon, "circle-dot"); + const headerInfo = headerLeft.createDiv(); + headerInfo.createDiv({ cls: "af-detail-header-name", text: task.taskId }); + headerInfo.createDiv({ + cls: "af-detail-header-desc", + text: `Agent: ${task.agent}`, + }); + + const headerActions = header.createDiv({ cls: "af-detail-header-actions" }); + + const editBtn = headerActions.createEl("button", { cls: "af-btn-sm" }); + createIcon(editBtn, "edit", "af-btn-icon"); + editBtn.appendText(" Edit"); + editBtn.onclick = () => deps.navigate("edit-task", task.taskId); + + const runBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" }); + createIcon(runBtn, "play", "af-btn-icon"); + runBtn.appendText(" Run Now"); + runBtn.onclick = () => void deps.plugin.runtime.runTaskNow(task); + + // Details section + const details = page.createDiv({ cls: "af-section-card" }); + const detailsHeader = details.createDiv({ cls: "af-section-header" }); + const detailsTitle = detailsHeader.createDiv({ cls: "af-section-title" }); + createIcon(detailsTitle, "file-text"); + detailsTitle.appendText(" Details"); + + const detailsBody = details.createDiv({ cls: "af-config-form" }); + deps.renderConfigRow(detailsBody, "Agent", task.agent); + deps.renderConfigRow(detailsBody, "Priority", task.priority.charAt(0).toUpperCase() + task.priority.slice(1)); + deps.renderConfigRow(detailsBody, "Status", task.enabled ? "Enabled" : "Disabled"); + + // Schedule — human-readable, no raw cron + const scheduleText = task.schedule + ? deps.humanizeCron(task.schedule) + : task.runAt ?? "Manual (run on demand)"; + deps.renderConfigRow(detailsBody, "Schedule", scheduleText); + if (task.schedule) { + deps.renderConfigRow(detailsBody, "Catch up if missed", task.catchUp ? "Yes" : "No"); + } + + deps.renderConfigRow(detailsBody, "Created", task.created); + deps.renderConfigRow(detailsBody, "Runs", String(task.runCount)); + if (task.lastRun) { + deps.renderConfigRow(detailsBody, "Last Run", deps.formatStarted(task.lastRun)); + } + + // Instructions section + const promptSection = page.createDiv({ cls: "af-section-card" }); + const promptHeader = promptSection.createDiv({ cls: "af-section-header" }); + const promptTitle = promptHeader.createDiv({ cls: "af-section-title" }); + createIcon(promptTitle, "message-square"); + promptTitle.appendText(" Instructions"); + promptSection.createDiv({ cls: "af-output-block", text: task.body || "(empty)" }); + + // Recent runs + const runsSection = page.createDiv({ cls: "af-section-card" }); + const runsHeader = runsSection.createDiv({ cls: "af-section-header" }); + const runsTitle = runsHeader.createDiv({ cls: "af-section-title" }); + createIcon(runsTitle, "scroll-text"); + runsTitle.appendText(" Recent Runs"); + + const runsBody = runsSection.createDiv({ cls: "af-timeline" }); + if (runs.length === 0) { + deps.renderEmptyState(runsBody, "scroll-text", "No runs yet", ""); + } else { + for (const run of runs.slice(0, 10)) { + deps.renderTimelineItem(runsBody, run); + } + } +} diff --git a/src/views/pages/wikiKeepersPage.ts b/src/views/pages/wikiKeepersPage.ts new file mode 100644 index 0000000..969dc6c --- /dev/null +++ b/src/views/pages/wikiKeepersPage.ts @@ -0,0 +1,193 @@ +import { Notice, TFile } from "obsidian"; +import type { AgentConfig } from "../../types"; +import { parseLatestLintReport } from "../../utils/wikiLintReport"; +import type { DashboardPageDeps } from "./shared"; + +/** View helpers the Wiki Keepers page borrows from the dashboard so the + * extracted markup stays byte-identical to what the view used to render + * inline. */ +export type WikiKeepersPageDeps = DashboardPageDeps; + +/** + * Wiki Keepers page: lists every Wiki Keeper instance with the latest + * lint report parsed from its scope's `log.md`. "Needs review" items + * render as cards with a Dismiss button (in-memory only — re-parse on + * navigation gives the user a fresh view of the most recent lint pass). + */ +export async function renderWikiKeepersPage(container: HTMLElement, deps: WikiKeepersPageDeps): Promise { + const page = container.createDiv({ cls: "af-agents-page" }); + const snapshot = deps.plugin.runtime.getSnapshot(); + + const toolbar = page.createDiv({ cls: "af-agents-toolbar" }); + toolbar.createDiv({ cls: "af-page-title", text: "Wiki Keepers" }); + toolbar.createDiv({ cls: "af-toolbar-spacer" }); + + const keepers = snapshot.agents.filter( + (a): a is AgentConfig & { wikiKeeper: NonNullable } => + a.wikiKeeper !== undefined, + ); + + if (keepers.length === 0) { + deps.renderEmptyState( + page, + "library", + "No Wiki Keepers yet", + "Open Settings → Agent Fleet → Wiki Keepers → + Add to create one.", + ); + return; + } + + const list = page.createDiv({ cls: "af-wk-list" }); + list.setCssStyles({ display: "flex" }); + list.setCssStyles({ flexDirection: "column" }); + list.setCssStyles({ gap: "16px" }); + + for (const keeper of keepers) { + await renderWikiKeeperCard(list, deps, keeper); + } +} + +async function renderWikiKeeperCard( + container: HTMLElement, + deps: WikiKeepersPageDeps, + agent: AgentConfig & { wikiKeeper: NonNullable }, +): Promise { + const wk = agent.wikiKeeper; + const card = container.createDiv({ cls: "af-card" }); + card.setCssStyles({ padding: "16px" }); + card.setCssStyles({ border: "1px solid var(--background-modifier-border)" }); + card.setCssStyles({ borderRadius: "8px" }); + + const header = card.createDiv(); + header.setCssStyles({ display: "flex" }); + header.setCssStyles({ alignItems: "center" }); + header.setCssStyles({ gap: "12px" }); + header.setCssStyles({ marginBottom: "12px" }); + const titleWrap = header.createDiv(); + titleWrap.setCssStyles({ flex: "1" }); + titleWrap.createEl("strong", { text: agent.name }); + const scopeLabel = wk.scopeRoot || "(whole vault)"; + titleWrap.createEl("div", { + text: `Scope: ${scopeLabel} · topics: ${wk.topicsRoot}/ · log: ${wk.logPath}`, + cls: "af-form-hint", + }); + + const openBtn = header.createEl("button", { cls: "af-btn-sm" }); + openBtn.appendText("Open log"); + openBtn.onclick = () => { + const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath; + const file = deps.plugin.app.vault.getAbstractFileByPath(logFullPath); + if (file instanceof TFile) { + void deps.plugin.app.workspace.getLeaf().openFile(file); + } else { + new Notice(`Log file not found: ${logFullPath}`); + } + }; + + // Read and parse log.md + const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath; + const logFile = deps.plugin.app.vault.getAbstractFileByPath(logFullPath); + let report: ReturnType = null; + if (logFile instanceof TFile) { + try { + const content = await deps.plugin.app.vault.cachedRead(logFile); + report = parseLatestLintReport(content); + } catch { + report = null; + } + } + + if (!report) { + const empty = card.createDiv({ cls: "af-form-hint" }); + empty.setText( + "No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.", + ); + return; + } + + // Latest lint header + const reportHeader = card.createDiv(); + reportHeader.setCssStyles({ display: "flex" }); + reportHeader.setCssStyles({ alignItems: "baseline" }); + reportHeader.setCssStyles({ gap: "12px" }); + reportHeader.setCssStyles({ marginBottom: "8px" }); + reportHeader.createEl("strong", { text: `Lint ${report.date}` }); + reportHeader.createSpan({ + cls: "af-form-hint", + text: `${report.summary.length} summary lines · ${report.autoApplied.length} auto-applied · ${report.needsReview.length} needs review`, + }); + + // Summary bullets (read-only) + if (report.summary.length > 0) { + const sumDetails = card.createEl("details"); + sumDetails.createEl("summary", { text: "Summary" }); + const sumList = sumDetails.createEl("ul"); + sumList.setCssStyles({ marginTop: "4px" }); + for (const item of report.summary) { + sumList.createEl("li", { text: item }); + } + } + + if (report.autoApplied.length > 0) { + const autoDetails = card.createEl("details"); + autoDetails.createEl("summary", { text: `Auto-applied (${report.autoApplied.length})` }); + const autoList = autoDetails.createEl("ul"); + autoList.setCssStyles({ marginTop: "4px" }); + for (const item of report.autoApplied) { + autoList.createEl("li", { text: item }); + } + } + + if (report.refreshChained.length > 0) { + const refDetails = card.createEl("details"); + refDetails.createEl("summary", { + text: `Refresh chained (${report.refreshChained.length})`, + }); + const refList = refDetails.createEl("ul"); + refList.setCssStyles({ marginTop: "4px" }); + for (const item of report.refreshChained) { + refList.createEl("li", { text: item }); + } + } + + // Needs review queue — the actionable bit + const reviewWrap = card.createDiv(); + reviewWrap.setCssStyles({ marginTop: "12px" }); + reviewWrap.createEl("strong", { text: `Needs review (${report.needsReview.length})` }); + + if (report.needsReview.length === 0) { + reviewWrap.createDiv({ + cls: "af-form-hint", + text: "All clear. Nothing requires manual review from this lint pass.", + }); + return; + } + + const reviewList = reviewWrap.createDiv(); + reviewList.setCssStyles({ display: "flex" }); + reviewList.setCssStyles({ flexDirection: "column" }); + reviewList.setCssStyles({ gap: "6px" }); + reviewList.setCssStyles({ marginTop: "8px" }); + + for (const item of report.needsReview) { + const row = reviewList.createDiv(); + row.setCssStyles({ display: "flex" }); + row.setCssStyles({ alignItems: "flex-start" }); + row.setCssStyles({ gap: "8px" }); + row.setCssStyles({ padding: "8px 10px" }); + row.setCssStyles({ background: "var(--background-secondary)" }); + row.setCssStyles({ borderRadius: "4px" }); + row.setCssStyles({ fontSize: "13px" }); + + const text = row.createDiv(); + text.setCssStyles({ flex: "1" }); + text.setText(item); + + const dismissBtn = row.createEl("button", { cls: "af-btn-sm", text: "Dismiss" }); + dismissBtn.title = + "Hide this item from the dashboard until the next lint pass (does not modify log.md)."; + dismissBtn.onclick = () => { + row.remove(); + }; + } +} diff --git a/styles.css b/styles.css index 4f7023f..7aadaee 100644 --- a/styles.css +++ b/styles.css @@ -334,6 +334,19 @@ height: 14px; } +.af-search-result-empty { + padding: 8px 12px; + font-size: 12px; + color: var(--af-text-muted); +} + +.af-search-result-footer { + padding: 6px 12px; + font-size: 11px; + color: var(--af-text-muted); + border-top: 1px solid var(--af-border); +} + /* ─── Status Pills ─── */ .af-status-pills { display: flex; @@ -649,10 +662,6 @@ height: 12px; } -.af-icon-back { - transform: rotate(180deg); -} - /* ─── Buttons ─── */ /* ─── Outlined Buttons ─── */ .af-btn-sm { @@ -1446,12 +1455,6 @@ margin-top: 16px; } -.af-detail-value-row { - display: flex; - align-items: center; - gap: 8px; -} - .af-run-list-item { display: flex; gap: 12px; @@ -1573,7 +1576,6 @@ } .af-kanban-running { border-color: rgba(249, 226, 175, 0.3); } -.af-kanban-review { border-color: rgba(137, 180, 250, 0.3); } .af-kanban-failed { border-color: rgba(243, 139, 168, 0.15); } /* Drag and drop states */ @@ -1801,23 +1803,6 @@ gap: 4px; } -.af-kanban-card-tags { - display: flex; - gap: 3px; -} - -.af-kanban-card-tag { - padding: 1px 6px; - border-radius: 3px; - font-size: 9px; - font-weight: 500; -} - -.af-kanban-card-tag.monitoring { background: var(--af-blue-bg); color: var(--af-blue); } -.af-kanban-card-tag.devops { background: var(--af-orange-bg); color: var(--af-orange); } -.af-kanban-card-tag.sample { background: var(--af-purple-bg); color: var(--af-purple); } -.af-kanban-card-tag.default { background: var(--af-bg-surface); color: var(--af-text-muted); } - .af-kanban-card-error { padding: 6px 0; font-size: 11px; @@ -1907,21 +1892,6 @@ background: var(--af-accent-bg); } -/* ─── Priority Badge (detail page) ─── */ -.af-priority-badge { - display: inline-block; - padding: 2px 8px; - border-radius: var(--af-radius-sm); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} -.af-priority-badge.critical { background: rgba(239, 68, 68, 0.15); color: #ef4444; } -.af-priority-badge.high { background: rgba(245, 158, 11, 0.15); color: #f59e0b; } -.af-priority-badge.medium { background: rgba(59, 130, 246, 0.15); color: #3b82f6; } -.af-priority-badge.low { background: var(--af-bg-surface); color: var(--af-text-secondary); } - /* ─── Schedule body toggle ─── */ .af-schedule-body { padding-left: 4px; @@ -2166,10 +2136,6 @@ word-break: break-word; } -.af-detail-value.af-accent { - color: var(--af-accent); -} - .af-output-block { background: var(--af-bg-primary); border: 1px solid var(--af-border); @@ -2281,6 +2247,10 @@ font-size: 12px; } +.af-empty-action { + margin-top: 12px; +} + /* ─── Responsive ─── */ @media (max-width: 1000px) { .af-dash-grid { @@ -2333,11 +2303,6 @@ margin: 8px 0 0 16px; } -.agent-fleet-textarea { - width: 100%; - margin: 8px 0 16px; -} - /* ─── Create Agent Modal ─── */ .af-create-agent-modal { max-width: 600px; @@ -2584,13 +2549,6 @@ line-height: 1; } -.af-form-desc { - font-size: 10px; - color: var(--af-text-muted); - font-weight: 400; - margin-top: 2px; -} - /* Info-icon tooltip — replaces verbose subtitles on form labels */ .af-form-tooltip { display: inline-flex; @@ -2970,34 +2928,6 @@ border-radius: 8px; } -/* ─── Chat Slideover ─── */ -.af-slideover.af-chat-slideover { - width: 540px; -} - -.af-chat-header-title { - display: flex; - align-items: center; - gap: 8px; -} - -.af-chat-header-actions { - display: flex; - align-items: center; - gap: 6px; -} - -.af-chat-header-icon { - display: flex; - align-items: center; - color: var(--af-accent); -} - -.af-chat-header-icon svg { - width: 16px; - height: 16px; -} - .af-chat-messages { flex: 1; overflow-y: auto; @@ -3298,13 +3228,6 @@ } } -.af-chat-working-indicator { - font-size: 11px; - color: var(--af-text-secondary); - padding: 0 0 6px 0; - opacity: 0.8; -} - .af-chat-input-area { border-top: 1px solid var(--af-border); padding: 12px 16px 16px 16px; @@ -3802,105 +3725,6 @@ font-size: 10px; } -/* ─── MCP Tool Item (simple list fallback) ─── */ -.af-mcp-tool-item { - font-family: var(--font-monospace); - font-size: 12px; - padding: 4px 8px; - background: var(--af-bg-surface); - border-radius: 4px; - margin-bottom: 4px; - color: var(--af-text-secondary); -} - -/* ── MCP Discovery Progress ── */ - -.af-mcp-progress { - margin: 0 0 20px 0; - padding: 16px 20px; - background: var(--af-card-bg); - border: 1px solid var(--af-border); - border-radius: 10px; -} - -.af-mcp-progress-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; -} - -.af-mcp-spinner { - display: flex; - gap: 4px; - align-items: center; -} - -.af-mcp-spinner span { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--af-blue); - animation: af-mcp-bounce 1.2s infinite ease-in-out; -} - -.af-mcp-spinner span:nth-child(2) { - animation-delay: 0.15s; -} - -.af-mcp-spinner span:nth-child(3) { - animation-delay: 0.3s; -} - -@keyframes af-mcp-bounce { - 0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); } - 30% { opacity: 1; transform: scale(1.1); } -} - -.af-mcp-progress-label { - font-size: 13px; - font-weight: 500; - color: var(--af-text-primary); -} - -.af-mcp-progress-bar { - height: 4px; - border-radius: 2px; - background: var(--af-border); - overflow: hidden; - margin-bottom: 8px; -} - -.af-mcp-progress-fill { - height: 100%; - border-radius: 2px; - background: var(--af-blue); - transition: width 0.4s ease; -} - -.af-mcp-progress-fill-slow { - /* When tool discovery is running (the slow phase), add a shimmer */ - background: linear-gradient( - 90deg, - var(--af-blue) 0%, - color-mix(in srgb, var(--af-blue) 60%, white) 50%, - var(--af-blue) 100% - ); - background-size: 200% 100%; - animation: af-mcp-shimmer 1.5s infinite linear; -} - -@keyframes af-mcp-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -.af-mcp-progress-detail { - font-size: 11px; - color: var(--af-text-secondary); - letter-spacing: 0.01em; -} - /* ── MCP Auth Button ── */ .af-mcp-auth-row { @@ -3932,24 +3756,6 @@ to { transform: rotate(360deg); } } -.af-mcp-hint-row { - font-size: 11px; - color: var(--af-text-secondary); - padding: 8px 0 0; - border-top: 1px solid var(--af-border); - margin-top: 10px; -} - -.af-mcp-hint-row .af-link { - cursor: pointer; - text-decoration: underline; - opacity: 0.8; -} - -.af-mcp-hint-row .af-link:hover { - opacity: 1; -} - /* ── MCP Slideover Actions ── */ .af-slideover-section .af-btn-sm { @@ -4467,10 +4273,6 @@ border-top: 1px solid var(--af-border); } -.af-wk-schedule-block { - margin-bottom: 12px; -} - /* ── Run-detail transcript disclosure ── */ .af-run-transcript { @@ -4520,12 +4322,6 @@ opacity: 0.92; } -.af-wk-schedule-block .af-form-hint { - font-size: 12px; - color: var(--af-text-secondary); - margin-bottom: 6px; -} - /* ─── Chat: conversations side rail ───────────────────────────────────── */ /* Adjacent to the messages column inside the chat view, this rail lists all * parallel conversations for the currently-selected agent. Styled to match @@ -4732,3 +4528,176 @@ .af-chat-convo-collapse-btn.is-collapsed .af-convo-toggle-bar { width: 8.33%; } + +/* Muted recovery hint below the message inside an error bubble */ +.af-chat-error-hint { + margin-top: 4px; + font-size: 11px; + color: var(--af-text-secondary); +} + +/* ─── Agent card: heartbeat status line ─────────────────────────────────── */ +/* Compact line above the card footer: heart-pulse icon + schedule/next-run + * text + a small quick-toggle (same update path as the Overview tab). */ +.af-agent-card-heartbeat { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + font-size: 11px; + color: var(--af-text-muted); +} + +.af-agent-card-hb-icon { + display: inline-flex; + align-items: center; + color: var(--af-green); + opacity: 0.7; +} + +.af-agent-card-hb-icon svg { + width: 13px; + height: 13px; +} + +.af-agent-card-hb-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Smaller variant of the standard pill toggle for inline card rows */ +.af-agent-card-toggle-sm { + width: 28px; + height: 16px; + border-radius: 8px; +} + +.af-agent-card-toggle-sm::after { + width: 12px; + height: 12px; +} + +.af-agent-card-toggle-sm.on::after { + transform: translateX(12px); +} + +/* ─── Agent form: adapter-switch permission mapping hint ────────────────── */ +/* Muted one-liner under the permission-mode field, shown only after the user + * switches the adapter, explaining how the mode vocabulary was translated. */ +.af-adapter-map-hint { + font-size: 11px; + color: var(--af-text-muted); + line-height: 1.5; +} + +/* ─── Task form: schedule type segmented control ────────────────────────── */ +.af-segmented { + display: inline-flex; + border: 1px solid var(--af-border); + border-radius: var(--af-radius-md); + overflow: hidden; +} + +.af-segmented-btn { + padding: 5px 14px; + font-size: 12px; + border: none; + border-radius: 0; + background: transparent; + color: var(--af-text-secondary); + cursor: pointer; + box-shadow: none; +} + +.af-segmented-btn + .af-segmented-btn { + border-left: 1px solid var(--af-border); +} + +.af-segmented-btn:hover { + color: var(--af-text-normal, var(--text-normal)); +} + +.af-segmented-btn.active { + background: var(--af-accent); + color: white; +} + +/* ─── Dashboard: first-run welcome card ─────────────────────────────────── */ +.af-welcome-card { + padding: 16px; + margin-bottom: 16px; + border: 1px solid var(--af-border); + border-radius: var(--af-radius-md); + background: var(--af-bg-card, var(--background-secondary)); +} + +.af-welcome-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.af-welcome-dismiss { + flex-shrink: 0; +} + +.af-welcome-sub { + margin-top: 4px; + font-size: 12px; + color: var(--af-text-secondary); +} + +.af-welcome-steps { + display: flex; + gap: 12px; + margin-top: 12px; + flex-wrap: wrap; +} + +.af-welcome-step { + flex: 1 1 180px; + display: flex; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--af-border); + border-radius: var(--af-radius-md); + cursor: pointer; + transition: border-color var(--af-transition), background var(--af-transition); +} + +.af-welcome-step:hover { + border-color: var(--af-accent); + background: var(--background-modifier-hover); +} + +.af-welcome-step-num { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--af-accent); + color: white; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.af-welcome-step-title { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; +} + +.af-welcome-step-desc { + margin-top: 2px; + font-size: 11px; + color: var(--af-text-muted); +} diff --git a/versions.json b/versions.json index e97fbb1..ba856b8 100644 --- a/versions.json +++ b/versions.json @@ -30,5 +30,6 @@ "0.13.5": "1.11.4", "0.13.6": "1.11.4", "0.14.0": "1.11.4", - "0.15.0": "1.11.4" -} \ No newline at end of file + "0.15.0": "1.11.4", + "0.16.0": "1.11.4" +}