palmier 0.6.9 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/commands/serve.js +16 -0
- package/dist/mcp-handler.d.ts +3 -0
- package/dist/mcp-handler.js +59 -3
- package/dist/mcp-tools.d.ts +16 -1
- package/dist/mcp-tools.js +23 -1
- package/dist/notification-store.d.ts +13 -0
- package/dist/notification-store.js +19 -0
- package/dist/pwa/assets/{index-CZejk2al.js → index-DLxrL0hR.js} +42 -42
- package/dist/pwa/assets/{web-C48txJFl.js → web-CBI458eN.js} +1 -1
- package/dist/pwa/assets/{web-zj8Blync.js → web-HDs03L2B.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/transports/http-transport.js +51 -3
- package/package.json +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/components/PlanDialog.tsx +5 -12
- package/palmier-server/pwa/src/components/TaskForm.tsx +6 -15
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/types.ts +0 -1
- package/palmier-server/server/src/index.ts +2 -0
- package/palmier-server/server/src/routes/device.ts +32 -0
- package/palmier-server/spec.md +13 -12
- package/src/commands/serve.ts +16 -1
- package/src/mcp-handler.ts +68 -3
- package/src/mcp-tools.ts +47 -1
- package/src/notification-store.ts +30 -0
- package/src/transports/http-transport.ts +49 -3
- package/test/agent-instructions.test.ts +36 -3
- package/test/notification-store.test.ts +57 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
import{W as p}from"./index-
|
|
1
|
+
import{W as p}from"./index-DLxrL0hR.js";class f extends p{constructor(){super(...arguments),this.group="CapacitorStorage"}async configure({group:e}){typeof e=="string"&&(this.group=e)}async get(e){return{value:this.impl.getItem(this.applyPrefix(e.key))}}async set(e){this.impl.setItem(this.applyPrefix(e.key),e.value)}async remove(e){this.impl.removeItem(this.applyPrefix(e.key))}async keys(){return{keys:this.rawKeys().map(t=>t.substring(this.prefix.length))}}async clear(){for(const e of this.rawKeys())this.impl.removeItem(e)}async migrate(){var e;const t=[],s=[],n="_cap_",o=Object.keys(this.impl).filter(i=>i.indexOf(n)===0);for(const i of o){const r=i.substring(n.length),a=(e=this.impl.getItem(i))!==null&&e!==void 0?e:"",{value:l}=await this.get({key:r});typeof l=="string"?s.push(r):(await this.set({key:r,value:a}),t.push(r))}return{migrated:t,existing:s}}async removeOld(){const e="_cap_",t=Object.keys(this.impl).filter(s=>s.indexOf(e)===0);for(const s of t)this.impl.removeItem(s)}get impl(){return window.localStorage}get prefix(){return this.group==="NativeStorage"?"":`${this.group}.`}rawKeys(){return Object.keys(this.impl).filter(e=>e.indexOf(this.prefix)===0)}applyPrefix(e){return this.prefix+e}}export{f as PreferencesWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{W as t}from"./index-
|
|
1
|
+
import{W as t}from"./index-DLxrL0hR.js";class s extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{s as AppWeb};
|
package/dist/pwa/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
9
9
|
<title>Palmier</title>
|
|
10
10
|
<meta name="description" content="Remote control for AI agents running on your own machine. Schedule tasks, approve permissions, and get push notifications." />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-DLxrL0hR.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-C6Lz09EY.css">
|
|
13
13
|
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
|
14
14
|
<body>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
try{self["workbox:core:7.3.0"]&&_()}catch{}const N=(n,...e)=>{let t=n;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},E=N;class h extends Error{constructor(e,t){const s=E(e,t);super(s),this.name=e,this.details=t}}const f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},U=n=>[f.prefix,n,f.suffix].filter(e=>e&&e.length>0).join("-"),O=n=>{for(const e of Object.keys(f))n(e)},L={updateDetails:n=>{O(e=>{typeof n[e]=="string"&&(f[e]=n[e])})},getGoogleAnalyticsName:n=>n||U(f.googleAnalytics),getPrecacheName:n=>n||U(f.precache),getPrefix:()=>f.prefix,getRuntimeName:n=>n||U(f.runtime),getSuffix:()=>f.suffix};function v(n,e){const t=e();return n.waitUntil(t),t}try{self["workbox:precaching:7.3.0"]&&_()}catch{}const A="__WB_REVISION__";function M(n){if(!n)throw new h("add-to-cache-list-unexpected-type",{entry:n});if(typeof n=="string"){const i=new URL(n,location.href);return{cacheKey:i.href,url:i.href}}const{revision:e,url:t}=n;if(!t)throw new h("add-to-cache-list-unexpected-type",{entry:n});if(!e){const i=new URL(t,location.href);return{cacheKey:i.href,url:i.href}}const s=new URL(t,location.href),a=new URL(t,location.href);return s.searchParams.set(A,e),{cacheKey:s.href,url:a.href}}class W{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const a=t.originalRequest.url;s?this.notUpdatedURLs.push(a):this.updatedURLs.push(a)}return s}}}class q{constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const a=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return a?new Request(a,{headers:t.headers}):t},this._precacheController=e}}let w;function S(){if(w===void 0){const n=new Response("");if("body"in n)try{new Response(n.body),w=!0}catch{w=!1}w=!1}return w}async function j(n,e){let t=null;if(n.url&&(t=new URL(n.url).origin),t!==self.location.origin)throw new h("cross-origin-copy-response",{origin:t});const s=n.clone(),i={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},r=S()?s.body:await s.blob();return new Response(r,i)}const D=n=>new URL(String(n),location.href).href.replace(new RegExp(`^${location.origin}`),"");function T(n,e){const t=new URL(n);for(const s of e)t.searchParams.delete(s);return t.href}async function H(n,e,t,s){const a=T(e.url,t);if(e.url===a)return n.match(e,s);const i=Object.assign(Object.assign({},s),{ignoreSearch:!0}),r=await n.keys(e,i);for(const c of r){const o=T(c.url,t);if(a===o)return n.match(c,s)}}class F{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}const B=new Set;async function $(){for(const n of B)await n()}function V(n){return new Promise(e=>setTimeout(e,n))}try{self["workbox:strategies:7.3.0"]&&_()}catch{}function C(n){return typeof n=="string"?new Request(n):n}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new F,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=C(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const r=await t.preloadResponse;if(r)return r}const a=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const r of this.iterateCallbacks("requestWillFetch"))s=await r({request:s.clone(),event:t})}catch(r){if(r instanceof Error)throw new h("plugin-error-request-will-fetch",{thrownErrorMessage:r.message})}const i=s.clone();try{let r;r=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const c of this.iterateCallbacks("fetchDidSucceed"))r=await c({event:t,request:i,response:r});return r}catch(r){throw a&&await this.runCallbacks("fetchDidFail",{error:r,event:t,originalRequest:a.clone(),request:i.clone()}),r}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=C(e);let s;const{cacheName:a,matchOptions:i}=this._strategy,r=await this.getCacheKey(t,"read"),c=Object.assign(Object.assign({},i),{cacheName:a});s=await caches.match(r,c);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:a,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(e,t){const s=C(e);await V(0);const a=await this.getCacheKey(s,"write");if(!t)throw new h("cache-put-with-no-response",{url:D(a.url)});const i=await this._ensureResponseSafeToCache(t);if(!i)return!1;const{cacheName:r,matchOptions:c}=this._strategy,o=await self.caches.open(r),l=this.hasCallback("cacheDidUpdate"),d=l?await H(o,a.clone(),["__WB_REVISION__"],c):null;try{await o.put(a,l?i.clone():i)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await $(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:r,oldResponse:d,newResponse:i.clone(),request:a,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let a=e;for(const i of this.iterateCallbacks("cacheKeyWillBeUsed"))a=C(await i({mode:t,request:a,event:this.event,params:this.params}));this._cacheKeys[s]=a}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield i=>{const r=Object.assign(Object.assign({},i),{state:s});return t[e](r)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(a=>a.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const a of this.iterateCallbacks("cacheWillUpdate"))if(t=await a({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class J{constructor(e={}){this.cacheName=L.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,a="params"in e?e.params:void 0,i=new G(this,{event:t,request:s,params:a}),r=this._getResponse(i,s,t),c=this._awaitComplete(r,i,s,t);return[r,c]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let a;try{if(a=await this._handle(t,e),!a||a.type==="error")throw new h("no-response",{url:t.url})}catch(i){if(i instanceof Error){for(const r of e.iterateCallbacks("handlerDidError"))if(a=await r({error:i,event:s,request:t}),a)break}if(!a)throw i}for(const i of e.iterateCallbacks("handlerWillRespond"))a=await i({event:s,request:t,response:a});return a}async _awaitComplete(e,t,s,a){let i,r;try{i=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:a,request:s,response:i}),await t.doneWaiting()}catch(c){c instanceof Error&&(r=c)}if(await t.runCallbacks("handlerDidComplete",{event:a,request:s,response:i,error:r}),t.destroy(),r)throw r}}class p extends J{constructor(e={}){e.cacheName=L.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(p.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const a=t.params||{};if(this._fallbackToNetwork){const i=a.integrity,r=e.integrity,c=!r||r===i;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?r||i:void 0})),i&&c&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new h("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new h("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,a]of this.plugins.entries())a!==p.copyRedirectedCacheableResponsesPlugin&&(a===p.defaultPrecacheCacheabilityPlugin&&(e=s),a.cacheWillUpdate&&t++);t===0?this.plugins.push(p.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}p.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:n}){return!n||n.status>=400?null:n}};p.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:n}){return n.redirected?await j(n):n}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new p({cacheName:L.getPrecacheName(e),plugins:[...t,new q({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:a,url:i}=M(s),r=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(i)&&this._urlsToCacheKeys.get(i)!==a)throw new h("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(i),secondEntry:a});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(a)&&this._cacheKeysToIntegrities.get(a)!==s.integrity)throw new h("add-to-cache-list-conflicting-integrities",{url:i});this._cacheKeysToIntegrities.set(a,s.integrity)}if(this._urlsToCacheKeys.set(i,a),this._urlsToCacheModes.set(i,r),t.length>0){const c=`Workbox is precaching URLs without revision info: ${t.join(", ")}
|
|
2
|
-
This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(c)}}}install(e){return v(e,async()=>{const t=new W;this.strategy.plugins.push(t);for(const[i,r]of this._urlsToCacheKeys){const c=this._cacheKeysToIntegrities.get(r),o=this._urlsToCacheModes.get(i),l=new Request(i,{integrity:c,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:r},request:l,event:e}))}const{updatedURLs:s,notUpdatedURLs:a}=t;return{updatedURLs:s,notUpdatedURLs:a}})}activate(e){return v(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),a=new Set(this._urlsToCacheKeys.values()),i=[];for(const r of s)a.has(r.url)||(await t.delete(r),i.push(r.url));return{deletedURLs:i}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new h("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let k;const x=()=>(k||(k=new Q),k);try{self["workbox:routing:7.3.0"]&&_()}catch{}const I="GET",b=n=>n&&typeof n=="object"?n:{handle:n};class R{constructor(e,t,s=I){this.handler=b(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=b(e)}}class z extends R{constructor(e,t,s){const a=({url:i})=>{const r=e.exec(i.href);if(r&&!(i.origin!==location.origin&&r.index!==0))return r.slice(1)};super(a,t,s)}}class X{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(a=>{typeof a=="string"&&(a=[a]);const i=new Request(...a);return this.handleRequest({request:i,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const a=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:t,request:e,sameOrigin:a,url:s});let c=r&&r.handler;const o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;let l;try{l=c.handle({url:s,request:e,event:t,params:i})}catch(u){l=Promise.reject(u)}const d=r&&r.catchHandler;return l instanceof Promise&&(this._catchHandler||d)&&(l=l.catch(async u=>{if(d)try{return await d.handle({url:s,request:e,event:t,params:i})}catch(g){g instanceof Error&&(u=g)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),l}findMatchingRoute({url:e,sameOrigin:t,request:s,event:a}){const i=this._routes.get(s.method)||[];for(const r of i){let c;const o=r.match({url:e,sameOrigin:t,request:s,event:a});if(o)return c=o,(Array.isArray(c)&&c.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(c=void 0),{route:r,params:c}}return{}}setDefaultHandler(e,t=I){this._defaultHandlerMap.set(t,b(e))}setCatchHandler(e){this._catchHandler=b(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new h("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new h("unregister-route-route-not-registered")}}let m;const Y=()=>(m||(m=new X,m.addFetchListener(),m.addCacheListener()),m);function Z(n,e,t){let s;if(typeof n=="string"){const i=new URL(n,location.href),r=({url:c})=>c.href===i.href;s=new R(r,e,t)}else if(n instanceof RegExp)s=new z(n,e,t);else if(typeof n=="function")s=new R(n,e,t);else if(n instanceof R)s=n;else throw new h("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return Y().registerRoute(s),s}function ee(n,e=[]){for(const t of[...n.searchParams.keys()])e.some(s=>s.test(t))&&n.searchParams.delete(t);return n}function*te(n,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:a}={}){const i=new URL(n,location.href);i.hash="",yield i.href;const r=ee(i,e);if(yield r.href,t&&r.pathname.endsWith("/")){const c=new URL(r.href);c.pathname+=t,yield c.href}if(s){const c=new URL(r.href);c.pathname+=".html",yield c.href}if(a){const c=a({url:i});for(const o of c)yield o.href}}class se extends R{constructor(e,t){const s=({request:a})=>{const i=e.getURLsToCacheKeys();for(const r of te(a.url,t)){const c=i.get(r);if(c){const o=e.getIntegrityForCacheKey(c);return{cacheKey:c,integrity:o}}}};super(s,e.strategy)}}function ne(n){const e=x(),t=new se(e,n);Z(t)}function ae(n){x().precache(n)}function ie(n,e){ae(n),ne(e)}ie([{"revision":"38013143dc2183340ede8bc1c5124507","url":"registerSW.js"},{"revision":"
|
|
2
|
+
This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(c)}}}install(e){return v(e,async()=>{const t=new W;this.strategy.plugins.push(t);for(const[i,r]of this._urlsToCacheKeys){const c=this._cacheKeysToIntegrities.get(r),o=this._urlsToCacheModes.get(i),l=new Request(i,{integrity:c,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:r},request:l,event:e}))}const{updatedURLs:s,notUpdatedURLs:a}=t;return{updatedURLs:s,notUpdatedURLs:a}})}activate(e){return v(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),a=new Set(this._urlsToCacheKeys.values()),i=[];for(const r of s)a.has(r.url)||(await t.delete(r),i.push(r.url));return{deletedURLs:i}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new h("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let k;const x=()=>(k||(k=new Q),k);try{self["workbox:routing:7.3.0"]&&_()}catch{}const I="GET",b=n=>n&&typeof n=="object"?n:{handle:n};class R{constructor(e,t,s=I){this.handler=b(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=b(e)}}class z extends R{constructor(e,t,s){const a=({url:i})=>{const r=e.exec(i.href);if(r&&!(i.origin!==location.origin&&r.index!==0))return r.slice(1)};super(a,t,s)}}class X{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(a=>{typeof a=="string"&&(a=[a]);const i=new Request(...a);return this.handleRequest({request:i,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const a=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:t,request:e,sameOrigin:a,url:s});let c=r&&r.handler;const o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;let l;try{l=c.handle({url:s,request:e,event:t,params:i})}catch(u){l=Promise.reject(u)}const d=r&&r.catchHandler;return l instanceof Promise&&(this._catchHandler||d)&&(l=l.catch(async u=>{if(d)try{return await d.handle({url:s,request:e,event:t,params:i})}catch(g){g instanceof Error&&(u=g)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),l}findMatchingRoute({url:e,sameOrigin:t,request:s,event:a}){const i=this._routes.get(s.method)||[];for(const r of i){let c;const o=r.match({url:e,sameOrigin:t,request:s,event:a});if(o)return c=o,(Array.isArray(c)&&c.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(c=void 0),{route:r,params:c}}return{}}setDefaultHandler(e,t=I){this._defaultHandlerMap.set(t,b(e))}setCatchHandler(e){this._catchHandler=b(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new h("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new h("unregister-route-route-not-registered")}}let m;const Y=()=>(m||(m=new X,m.addFetchListener(),m.addCacheListener()),m);function Z(n,e,t){let s;if(typeof n=="string"){const i=new URL(n,location.href),r=({url:c})=>c.href===i.href;s=new R(r,e,t)}else if(n instanceof RegExp)s=new z(n,e,t);else if(typeof n=="function")s=new R(n,e,t);else if(n instanceof R)s=n;else throw new h("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return Y().registerRoute(s),s}function ee(n,e=[]){for(const t of[...n.searchParams.keys()])e.some(s=>s.test(t))&&n.searchParams.delete(t);return n}function*te(n,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:a}={}){const i=new URL(n,location.href);i.hash="",yield i.href;const r=ee(i,e);if(yield r.href,t&&r.pathname.endsWith("/")){const c=new URL(r.href);c.pathname+=t,yield c.href}if(s){const c=new URL(r.href);c.pathname+=".html",yield c.href}if(a){const c=a({url:i});for(const o of c)yield o.href}}class se extends R{constructor(e,t){const s=({request:a})=>{const i=e.getURLsToCacheKeys();for(const r of te(a.url,t)){const c=i.get(r);if(c){const o=e.getIntegrityForCacheKey(c);return{cacheKey:c,integrity:o}}}};super(s,e.strategy)}}function ne(n){const e=x(),t=new se(e,n);Z(t)}function ae(n){x().precache(n)}function ie(n,e){ae(n),ne(e)}ie([{"revision":"38013143dc2183340ede8bc1c5124507","url":"registerSW.js"},{"revision":"f65782768cfd2a3d2392e43cf922dde4","url":"index.html"},{"revision":null,"url":"assets/web-HDs03L2B.js"},{"revision":null,"url":"assets/web-CBI458eN.js"},{"revision":null,"url":"assets/index-DLxrL0hR.js"},{"revision":null,"url":"assets/index-C6Lz09EY.css"},{"revision":"fcc457fce855ad0df7178e0786c0d4ef","url":"apple-touch-icon.png"},{"revision":"276650c30bc4effc7d649ec66519aab6","url":"favicon.ico"},{"revision":"2e46512b835c05e17787059909305f22","url":"pwa-192x192.png"},{"revision":"ec5652b5834b4711337743e80e506a41","url":"pwa-512x512.png"},{"revision":"9f51698004b9cc4d787c75695b74de9d","url":"manifest.webmanifest"}]);const re="/api/push/respond";self.addEventListener("message",n=>{});self.addEventListener("push",n=>{var r;if(!n.data)return;let e;try{e=n.data.json()}catch{e={title:"Palmier",body:n.data.text()}}const t=e.type??((r=e.data)==null?void 0:r.type);if(t==="confirm-dismiss"||t==="permission-dismiss"||t==="input-dismiss"){const c=e.data??e,o=c.host_id,l=c.session_id,d=c.task_id;n.waitUntil(self.registration.getNotifications().then(u=>{var g,P,K;for(const y of u)if(((g=y.data)==null?void 0:g.host_id)===o){if(l&&((P=y.data)==null?void 0:P.session_id)===l){y.close();continue}d&&((K=y.data)==null?void 0:K.task_id)===d&&y.close()}}));return}const s=e.title??"Palmier";let a=e.body??"";!a&&t==="confirm"&&(a="A task requires confirmation to run."),!a&&t==="permission"&&(a="A task needs additional permissions to continue."),!a&&t==="input"&&(a="A task needs your input to continue.");const i={body:a,icon:"/pwa-192x192.png",badge:"/pwa-192x192.png",data:e.data??e,vibrate:[100,50,100]};t==="confirm"&&(i.actions=[{action:"confirm",title:"Confirm"},{action:"abort",title:"Abort"}]),n.waitUntil(self.registration.showNotification(s,i))});self.addEventListener("notificationclick",n=>{const e=n.notification;e.close();const t=e.data??{},s=n.action;if(s&&t.type==="confirm"&&t.session_id&&t.host_id){const a=s==="confirm"?"confirmed":"aborted";n.waitUntil(fetch(re,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({session_id:t.session_id,host_id:t.host_id,response:a})}).catch(i=>{console.error("Failed to send push response:",i)}))}else{const a=t.task_id,i=t.run_id,r=a&&i?`/runs/${encodeURIComponent(a)}/${encodeURIComponent(i)}`:a?`/runs/${encodeURIComponent(a)}/latest`:"/";n.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(c=>{for(const o of c)if(o.url.includes(self.location.origin)&&"focus"in o)return o.navigate(r),o.focus();return self.clients.openWindow(r)}))}});self.addEventListener("install",()=>{self.skipWaiting()});self.addEventListener("activate",n=>{n.waitUntil(self.clients.claim())});
|
|
@@ -5,9 +5,10 @@ import { StringCodec } from "nats";
|
|
|
5
5
|
import { validateClient, addClient } from "../client-store.js";
|
|
6
6
|
import { registerPending } from "../pending-requests.js";
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
|
-
import { agentToolMap, ToolError } from "../mcp-tools.js";
|
|
9
|
-
import { handleMcpRequest, getAgentName } from "../mcp-handler.js";
|
|
8
|
+
import { agentToolMap, agentResources, ToolError } from "../mcp-tools.js";
|
|
9
|
+
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
10
10
|
import { getTaskDir } from "../task.js";
|
|
11
|
+
import { onNotificationsChanged } from "../notification-store.js";
|
|
11
12
|
const assetCache = new Map();
|
|
12
13
|
const PWA_DIR = path.join(import.meta.dirname, "..", "pwa");
|
|
13
14
|
const CONTENT_TYPES = {
|
|
@@ -73,8 +74,27 @@ export function detectLanIp() {
|
|
|
73
74
|
*/
|
|
74
75
|
export async function startHttpTransport(config, handleRpc, port, nc, pairingCode, onReady) {
|
|
75
76
|
const sseClients = new Set();
|
|
77
|
+
const mcpStreams = new Map();
|
|
76
78
|
const lanEnabled = config.lanEnabled ?? false;
|
|
77
79
|
const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
80
|
+
/** Push notifications/resources/updated to all MCP clients subscribed to the given URI. */
|
|
81
|
+
function broadcastResourceUpdated(uri) {
|
|
82
|
+
const subs = getResourceSubscriptions();
|
|
83
|
+
for (const [sessionId, uris] of subs) {
|
|
84
|
+
if (!uris.has(uri))
|
|
85
|
+
continue;
|
|
86
|
+
const stream = mcpStreams.get(sessionId);
|
|
87
|
+
if (!stream)
|
|
88
|
+
continue;
|
|
89
|
+
stream.write(`data: ${JSON.stringify({
|
|
90
|
+
jsonrpc: "2.0",
|
|
91
|
+
method: "notifications/resources/updated",
|
|
92
|
+
params: { uri },
|
|
93
|
+
})}\n\n`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Wire up resource change listeners
|
|
97
|
+
onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
|
|
78
98
|
// If a pairing code is provided, pre-register it
|
|
79
99
|
if (pairingCode) {
|
|
80
100
|
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -146,7 +166,25 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
146
166
|
if (result.sessionId) {
|
|
147
167
|
res.setHeader("Mcp-Session-Id", result.sessionId);
|
|
148
168
|
}
|
|
149
|
-
|
|
169
|
+
if (result.stream && sessionId) {
|
|
170
|
+
// Keep response open as SSE stream for server-initiated notifications
|
|
171
|
+
res.writeHead(200, {
|
|
172
|
+
"Content-Type": "text/event-stream",
|
|
173
|
+
"Cache-Control": "no-cache",
|
|
174
|
+
"Connection": "keep-alive",
|
|
175
|
+
});
|
|
176
|
+
res.write(`data: ${JSON.stringify(result.body)}\n\n`);
|
|
177
|
+
mcpStreams.set(sessionId, res);
|
|
178
|
+
const heartbeat = setInterval(() => { res.write(":heartbeat\n\n"); }, 15_000);
|
|
179
|
+
req.on("close", () => {
|
|
180
|
+
clearInterval(heartbeat);
|
|
181
|
+
mcpStreams.delete(sessionId);
|
|
182
|
+
getResourceSubscriptions().delete(sessionId);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
sendJson(res, 200, result.body);
|
|
187
|
+
}
|
|
150
188
|
}
|
|
151
189
|
catch (err) {
|
|
152
190
|
sendJson(res, 500, { error: String(err) });
|
|
@@ -186,6 +224,16 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
186
224
|
}
|
|
187
225
|
return;
|
|
188
226
|
}
|
|
227
|
+
// ── Auto-generated REST endpoints from MCP resource registry ────
|
|
228
|
+
const matchedResource = req.method === "GET" && agentResources.find((r) => r.restPath === pathname);
|
|
229
|
+
if (matchedResource) {
|
|
230
|
+
if (!isLocalhost(req)) {
|
|
231
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
sendJson(res, 200, matchedResource.read());
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
189
237
|
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
190
238
|
if (req.method === "POST" && pathname === "/event") {
|
|
191
239
|
if (!isLocalhost(req)) {
|
package/package.json
CHANGED
package/palmier-server/README.md
CHANGED
|
@@ -181,7 +181,7 @@ All endpoints are prefixed with `/api`. No user authentication is required.
|
|
|
181
181
|
- **Static file serving** is conditional — Express only serves `pwa/dist/` if the directory exists, so it doesn't interfere during dev when using Vite.
|
|
182
182
|
- **No CORS** needed — Vite proxy handles same-origin in dev, Express static serving handles it in production.
|
|
183
183
|
- **Push notifications** — the PWA registers a service worker (`injectManifest` strategy via vite-plugin-pwa) and subscribes the browser for Web Push. The Web Server subscribes to `host-event.>` and sends push notifications for confirmation requests, task completions, and task failures.
|
|
184
|
-
- **Markdown rendering** —
|
|
184
|
+
- **Markdown rendering** — Task results are rendered as rich formatted text using `react-markdown` with `remark-gfm` (GitHub Flavored Markdown), supporting tables, strikethrough, task lists, and autolinks.
|
|
185
185
|
- **Task confirmation** — the Dashboard discovers pending confirmations from the `task.list` RPC response (tasks with a pending request in the serve daemon's in-memory registry, reported via `task.status`). When found, it shows a full-screen confirmation modal. Push notification action buttons trigger `POST /api/push/respond`, which forwards to the `task.user_input` NATS RPC.
|
|
186
186
|
- **Task event tracking** — task lifecycle events are persisted to `status.json` on the host (for crash detection) and broadcast via `host-event.<host_id>.<task_id>` pub/sub and HTTP SSE. The PWA loads initial status from `task.list` and subscribes to events for real-time updates.
|
|
187
187
|
- **NATS config** (`nats.conf`) enables WebSocket on port 9222 (for browser clients) and token-based auth.
|
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
import Markdown from "react-markdown";
|
|
2
|
-
import remarkGfm from "remark-gfm";
|
|
3
1
|
import type { RequiredPermission } from "../types";
|
|
4
2
|
|
|
5
3
|
interface PlanDialogProps {
|
|
6
|
-
body: string;
|
|
7
4
|
permissions?: RequiredPermission[];
|
|
8
5
|
}
|
|
9
6
|
|
|
10
|
-
export default function PlanDialog({
|
|
7
|
+
export default function PlanDialog({ permissions }: PlanDialogProps) {
|
|
11
8
|
return (
|
|
12
9
|
<div className="plan-dialog">
|
|
13
|
-
<h2>
|
|
10
|
+
<h2>Granted Permissions</h2>
|
|
14
11
|
<div className="plan-dialog-scroll">
|
|
15
|
-
{
|
|
16
|
-
<div className="plan-preview"><Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown></div>
|
|
17
|
-
) : (
|
|
18
|
-
<p className="plan-empty">No execution plan generated for this task. Your task description will be used directly.</p>
|
|
19
|
-
)}
|
|
20
|
-
{permissions && permissions.length > 0 && (
|
|
12
|
+
{permissions && permissions.length > 0 ? (
|
|
21
13
|
<div className="permissions-section">
|
|
22
|
-
<h3>Granted Permissions</h3>
|
|
23
14
|
<ul className="permissions-list">
|
|
24
15
|
{permissions.map((p, i) => (
|
|
25
16
|
<li key={i} className="permission-item">
|
|
@@ -29,6 +20,8 @@ export default function PlanDialog({ body, permissions }: PlanDialogProps) {
|
|
|
29
20
|
))}
|
|
30
21
|
</ul>
|
|
31
22
|
</div>
|
|
23
|
+
) : (
|
|
24
|
+
<p className="plan-empty">No permissions have been granted for this task.</p>
|
|
32
25
|
)}
|
|
33
26
|
</div>
|
|
34
27
|
<div className="plan-dialog-actions">
|
|
@@ -83,16 +83,8 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
83
83
|
const [userPrompt, setUserPrompt] = useState(initial?.user_prompt ?? "");
|
|
84
84
|
const [agent, setAgent] = useState(initial?.agent ?? defaultAgent());
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
// Track whether prompt or agent diverged from the saved values (for existing tasks)
|
|
90
|
-
const promptChanged = !!initial && userPrompt !== (initial.user_prompt ?? "");
|
|
91
|
-
const agentChanged = !!initial && agent !== (initial.agent ?? "");
|
|
92
|
-
const planInvalidated = promptChanged || agentChanged;
|
|
93
|
-
|
|
94
|
-
// Show plan link for existing tasks that have a plan or permissions and haven't been modified
|
|
95
|
-
const hasPlan = !!initial && (!!body || !!initial.permissions?.length) && !planInvalidated;
|
|
86
|
+
// Show permissions link for existing tasks that have granted permissions
|
|
87
|
+
const hasPermissions = !!initial?.permissions?.length;
|
|
96
88
|
|
|
97
89
|
// Plan dialog (view-only for existing tasks)
|
|
98
90
|
const [planDialogOpen, setPlanDialogOpen] = useState(false);
|
|
@@ -195,8 +187,8 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
195
187
|
if (isEdit) {
|
|
196
188
|
payload.id = initial!.id;
|
|
197
189
|
}
|
|
198
|
-
//
|
|
199
|
-
const result = await request<Task & { error?: string }>(method, payload, { timeout:
|
|
190
|
+
// Name generation happens server-side, allow up to 45s
|
|
191
|
+
const result = await request<Task & { error?: string }>(method, payload, { timeout: 45000 });
|
|
200
192
|
if (result.error) {
|
|
201
193
|
setError(result.error);
|
|
202
194
|
return null;
|
|
@@ -247,7 +239,6 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
247
239
|
<div className="task-form">
|
|
248
240
|
{planDialogOpen ? (
|
|
249
241
|
<PlanDialog
|
|
250
|
-
body={body}
|
|
251
242
|
permissions={initial?.permissions}
|
|
252
243
|
/>
|
|
253
244
|
) : (<>
|
|
@@ -270,12 +261,12 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
270
261
|
/>
|
|
271
262
|
|
|
272
263
|
<div className="plan-actions">
|
|
273
|
-
{
|
|
264
|
+
{hasPermissions && (
|
|
274
265
|
<button
|
|
275
266
|
className="btn btn-link"
|
|
276
267
|
onClick={() => setPlanDialogOpen(true)}
|
|
277
268
|
>
|
|
278
|
-
|
|
269
|
+
Granted Permissions
|
|
279
270
|
</button>
|
|
280
271
|
)}
|
|
281
272
|
<div className="agent-picker-section-inline" style={{ marginLeft: "auto" }}>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.6.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.6.9";
|
|
@@ -10,6 +10,7 @@ import { StringCodec } from "nats";
|
|
|
10
10
|
import hostsRoutes from "./routes/hosts.js";
|
|
11
11
|
import pushRoutes from "./routes/push.js";
|
|
12
12
|
import fcmRoutes from "./routes/fcm.js";
|
|
13
|
+
import deviceRoutes from "./routes/device.js";
|
|
13
14
|
import { notifyClients } from "./notify.js";
|
|
14
15
|
import { sendFcmToClients, sendFcmToDevice } from "./fcm.js";
|
|
15
16
|
|
|
@@ -260,6 +261,7 @@ async function main(): Promise<void> {
|
|
|
260
261
|
app.use("/api/hosts", hostsRoutes);
|
|
261
262
|
app.use("/api/push", pushRoutes);
|
|
262
263
|
app.use("/api/fcm", fcmRoutes);
|
|
264
|
+
app.use("/api/device", deviceRoutes);
|
|
263
265
|
|
|
264
266
|
// Public NATS config endpoint (used by PWA for pairing)
|
|
265
267
|
app.get("/api/config", (_req, res) => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import type { Router as RouterType } from "express";
|
|
3
|
+
import { getNatsConnection } from "../nats.js";
|
|
4
|
+
import { StringCodec } from "nats";
|
|
5
|
+
|
|
6
|
+
const router: RouterType = Router();
|
|
7
|
+
|
|
8
|
+
// POST /api/device/notifications - Receive a notification from Android, relay to host via NATS
|
|
9
|
+
router.post("/notifications", async (req: Request, res: Response) => {
|
|
10
|
+
try {
|
|
11
|
+
const { hostId, notification } = req.body;
|
|
12
|
+
|
|
13
|
+
if (!hostId || !notification?.id) {
|
|
14
|
+
res.status(400).json({ error: "hostId and notification with id are required" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const conn = await getNatsConnection();
|
|
19
|
+
const sc = StringCodec();
|
|
20
|
+
conn.publish(
|
|
21
|
+
`host.${hostId}.device.notifications`,
|
|
22
|
+
sc.encode(JSON.stringify(notification)),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
res.json({ ok: true });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error("Device notification relay error:", err);
|
|
28
|
+
res.status(500).json({ error: "Internal server error" });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export default router;
|
package/palmier-server/spec.md
CHANGED
|
@@ -105,10 +105,10 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
|
|
|
105
105
|
|
|
106
106
|
| Method | Params | Description |
|
|
107
107
|
|---|---|---|
|
|
108
|
-
| `task.list` | *(none)* | List all tasks with frontmatter,
|
|
109
|
-
| `task.get` | `id` | Get a single task with frontmatter
|
|
110
|
-
| `task.create` | `user_prompt`, `agent`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated
|
|
111
|
-
| `task.update` | `id`, `user_prompt?`, `agent?`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates
|
|
108
|
+
| `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. Returns `agents` array of detected CLIs, `host_platform`, and `version`. |
|
|
109
|
+
| `task.get` | `id` | Get a single task with frontmatter and current status. |
|
|
110
|
+
| `task.create` | `user_prompt`, `agent`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if triggers present. |
|
|
111
|
+
| `task.update` | `id`, `user_prompt?`, `agent?`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. |
|
|
112
112
|
| `task.delete` | `id` | Delete a task and its systemd timers |
|
|
113
113
|
| `task.run` | `id` | Start a task via system scheduler (`systemctl --user start` / `schtasks /run`) |
|
|
114
114
|
| `task.abort` | `id` | Stop a running task via system scheduler (`systemctl --user stop` / `schtasks /end`) |
|
|
@@ -150,7 +150,7 @@ All tasks are stored locally on the Host machine under a `tasks/` directory rela
|
|
|
150
150
|
history.jsonl # Project-level run history index (append-only JSONL: { task_id, run_id })
|
|
151
151
|
tasks/
|
|
152
152
|
└── <task-id>/
|
|
153
|
-
├── TASK.md # Current task definition (frontmatter
|
|
153
|
+
├── TASK.md # Current task definition (YAML frontmatter)
|
|
154
154
|
├── status.json # Latest execution status (running_state, time_stamp, pid)
|
|
155
155
|
└── <timestamp>/ # Run directory (one per run, isolated per agent session)
|
|
156
156
|
├── TASKRUN.md # Conversational thread (frontmatter + message entries)
|
|
@@ -209,12 +209,13 @@ triggers:
|
|
|
209
209
|
triggers_enabled: true
|
|
210
210
|
requires_confirmation: true
|
|
211
211
|
---
|
|
212
|
-
[Detailed execution plan generated by the non-interactive generation step]
|
|
213
212
|
```
|
|
214
213
|
|
|
214
|
+
The `name` field is auto-generated by spawning the configured agent CLI with a short prompt (for prompts > 50 chars). For shorter prompts, the `user_prompt` is used directly as the name.
|
|
215
|
+
|
|
215
216
|
The `agent` field stores the agent name (e.g., `"claude"`, `"codex"`). The corresponding `AgentTool` implementation is responsible for constructing the full command and arguments at execution time.
|
|
216
217
|
|
|
217
|
-
The optional `command` field stores a shell command for command-triggered tasks. When set, the task runs in command-triggered mode: the command is spawned with `shell: true`, and each line of its stdout triggers a separate agent invocation with `user_prompt + "\n\nProcess this input:\n" + line`.
|
|
218
|
+
The optional `command` field stores a shell command for command-triggered tasks. When set, the task runs in command-triggered mode: the command is spawned with `shell: true`, and each line of its stdout triggers a separate agent invocation with `user_prompt + "\n\nProcess this input:\n" + line`.
|
|
218
219
|
|
|
219
220
|
#### Trigger Lifecycle
|
|
220
221
|
|
|
@@ -263,7 +264,7 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
263
264
|
|
|
264
265
|
3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload.
|
|
265
266
|
|
|
266
|
-
4. If the host responds, it returns `{ tasks: [...] }` — an array of **flat task objects** (frontmatter fields spread to the top level
|
|
267
|
+
4. If the host responds, it returns `{ tasks: [...] }` — an array of **flat task objects** (frontmatter fields spread to the top level) and displays the task list. If the request fails with NATS 503 ("no responders"), the PWA shows an empty task list — this is not treated as an error.
|
|
267
268
|
|
|
268
269
|
5. PWA registers the service worker and subscribes the browser for Web Push notifications (via `pushManager.subscribe` with the server's VAPID public key). The push subscription is sent to `POST /api/push/subscribe` with the `hostId` so the server can relay notifications to the device.
|
|
269
270
|
|
|
@@ -275,13 +276,13 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
275
276
|
|
|
276
277
|
2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Create" (or "Update" for existing tasks).
|
|
277
278
|
|
|
278
|
-
3. PWA sends `task.create` (or `task.update`) via NATS request-reply to Host (
|
|
279
|
+
3. PWA sends `task.create` (or `task.update`) via NATS request-reply to Host (45s timeout). For prompts > 50 chars, the host generates a concise task name by running the configured agent CLI in non-interactive mode (e.g., `claude -p "Generate a concise 3-6 word name for this task..."`). For shorter prompts, the prompt is used directly as the name.
|
|
279
280
|
|
|
280
|
-
4. For updates: if the user changes the `user_prompt` or `agent`, the
|
|
281
|
+
4. For updates: if the user changes the `user_prompt` or `agent`, the name is regenerated. If neither changed, the existing name is preserved. Existing tasks with granted permissions show a clickable "Granted Permissions" link to view them.
|
|
281
282
|
|
|
282
283
|
5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. The `triggers` field defaults to `[]` if omitted or undefined.
|
|
283
284
|
|
|
284
|
-
6. Host creates/updates the `tasks/<task-id>/TASK.md` file and returns the **full flat task object** (all frontmatter fields
|
|
285
|
+
6. Host creates/updates the `tasks/<task-id>/TASK.md` file and returns the **full flat task object** (all frontmatter fields at the top level). The PWA uses this response directly to update the UI.
|
|
285
286
|
|
|
286
287
|
7. **OS Integration:** Host translates triggers into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
|
|
287
288
|
|
|
@@ -328,7 +329,7 @@ When `palmier run <task-id>` executes (triggered by a systemd timer, `systemctl
|
|
|
328
329
|
|
|
329
330
|
* The spawned process inherits the default physical GUI session environment (`DISPLAY=:0`, `XDG_RUNTIME_DIR=/run/user/<uid>`) so that commands requiring a graphical display (e.g., headed browsers) run within the user's desktop session. `PALMIER_HTTP_PORT` is also set so agents can call the serve daemon's HTTP endpoints.
|
|
330
331
|
|
|
331
|
-
* The agent implementation is responsible for constructing the appropriate arguments (e.g., `--allowedTools` flags for Claude based on the task's permissions). The task
|
|
332
|
+
* The agent implementation is responsible for constructing the appropriate arguments (e.g., `--allowedTools` flags for Claude based on the task's permissions). The task's `user_prompt` is included in the arguments by the agent.
|
|
332
333
|
|
|
333
334
|
3. **Completion:**
|
|
334
335
|
|
package/src/commands/serve.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { detectAgents } from "../agents/agent.js";
|
|
|
12
12
|
import { saveConfig } from "../config.js";
|
|
13
13
|
import type { HostConfig } from "../types.js";
|
|
14
14
|
import { CONFIG_DIR } from "../config.js";
|
|
15
|
-
import type
|
|
15
|
+
import { StringCodec, type NatsConnection } from "nats";
|
|
16
|
+
import { addNotification } from "../notification-store.js";
|
|
16
17
|
|
|
17
18
|
const POLL_INTERVAL_MS = 30_000;
|
|
18
19
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
@@ -130,6 +131,20 @@ export async function serveCommand(): Promise<void> {
|
|
|
130
131
|
// Start NATS transport (loops forever, fire-and-forget)
|
|
131
132
|
if (nc) {
|
|
132
133
|
startNatsTransport(config, handleRpc, nc);
|
|
134
|
+
|
|
135
|
+
// Subscribe to device notifications from Android
|
|
136
|
+
const sc = StringCodec();
|
|
137
|
+
const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
|
|
138
|
+
(async () => {
|
|
139
|
+
for await (const msg of notifSub) {
|
|
140
|
+
try {
|
|
141
|
+
const data = JSON.parse(sc.decode(msg.data));
|
|
142
|
+
addNotification({ ...data, receivedAt: Date.now() });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error("[nats] Failed to parse device notification:", err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
// Start HTTP transport (loops forever)
|
package/src/mcp-handler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import { agentTools, agentToolMap, ToolError, type ToolContext } from "./mcp-tools.js";
|
|
2
|
+
import { agentTools, agentToolMap, agentResources, agentResourceMap, ToolError, type ToolContext } from "./mcp-tools.js";
|
|
3
3
|
|
|
4
4
|
interface JsonRpcRequest {
|
|
5
5
|
jsonrpc: string;
|
|
@@ -11,6 +11,15 @@ interface JsonRpcRequest {
|
|
|
11
11
|
export interface McpResponse {
|
|
12
12
|
body: object;
|
|
13
13
|
sessionId?: string;
|
|
14
|
+
/** If true, the HTTP transport should keep the response open as an SSE stream for server-initiated notifications. */
|
|
15
|
+
stream?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Resource subscriptions: sessionId → Set of resource URIs
|
|
19
|
+
const resourceSubscriptions = new Map<string, Set<string>>();
|
|
20
|
+
|
|
21
|
+
export function getResourceSubscriptions(): Map<string, Set<string>> {
|
|
22
|
+
return resourceSubscriptions;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
// Session-to-agent name map with 24h TTL
|
|
@@ -30,7 +39,10 @@ export function getAgentName(sessionId: string): string | undefined {
|
|
|
30
39
|
function pruneExpiredSessions(): void {
|
|
31
40
|
const now = Date.now();
|
|
32
41
|
for (const [id, entry] of sessionAgents) {
|
|
33
|
-
if (now > entry.expiresAt)
|
|
42
|
+
if (now > entry.expiresAt) {
|
|
43
|
+
sessionAgents.delete(id);
|
|
44
|
+
resourceSubscriptions.delete(id);
|
|
45
|
+
}
|
|
34
46
|
}
|
|
35
47
|
}
|
|
36
48
|
|
|
@@ -78,7 +90,7 @@ export async function handleMcpRequest(body: string, sessionId: string | undefin
|
|
|
78
90
|
return {
|
|
79
91
|
body: rpcResult(id, {
|
|
80
92
|
protocolVersion: "2025-03-26",
|
|
81
|
-
capabilities: { tools: {} },
|
|
93
|
+
capabilities: { tools: {}, resources: { subscribe: true } },
|
|
82
94
|
serverInfo: { name: "palmier", version: "1.0.0" },
|
|
83
95
|
}),
|
|
84
96
|
sessionId: newSessionId,
|
|
@@ -126,6 +138,59 @@ export async function handleMcpRequest(body: string, sessionId: string | undefin
|
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
case "resources/list": {
|
|
142
|
+
return {
|
|
143
|
+
body: rpcResult(id, {
|
|
144
|
+
resources: agentResources.map((r) => ({
|
|
145
|
+
uri: r.uri,
|
|
146
|
+
name: r.name,
|
|
147
|
+
description: r.description[0],
|
|
148
|
+
mimeType: r.mimeType,
|
|
149
|
+
})),
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "resources/read": {
|
|
155
|
+
const uri = req.params?.uri as string;
|
|
156
|
+
const resource = agentResourceMap.get(uri);
|
|
157
|
+
if (!resource) {
|
|
158
|
+
return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
body: rpcResult(id, {
|
|
162
|
+
contents: [{
|
|
163
|
+
uri: resource.uri,
|
|
164
|
+
mimeType: resource.mimeType,
|
|
165
|
+
text: JSON.stringify(resource.read()),
|
|
166
|
+
}],
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "resources/subscribe": {
|
|
172
|
+
const uri = req.params?.uri as string;
|
|
173
|
+
if (!agentResourceMap.has(uri)) {
|
|
174
|
+
return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
|
|
175
|
+
}
|
|
176
|
+
if (!sessionId) {
|
|
177
|
+
return { body: rpcError(id, -32600, "Session required for subscriptions") };
|
|
178
|
+
}
|
|
179
|
+
if (!resourceSubscriptions.has(sessionId)) {
|
|
180
|
+
resourceSubscriptions.set(sessionId, new Set());
|
|
181
|
+
}
|
|
182
|
+
resourceSubscriptions.get(sessionId)!.add(uri);
|
|
183
|
+
return { body: rpcResult(id, {}), stream: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "resources/unsubscribe": {
|
|
187
|
+
const uri = req.params?.uri as string;
|
|
188
|
+
if (sessionId) {
|
|
189
|
+
resourceSubscriptions.get(sessionId)?.delete(uri);
|
|
190
|
+
}
|
|
191
|
+
return { body: rpcResult(id, {}) };
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
default:
|
|
130
195
|
console.warn(`${logPrefix} Unknown method: ${req.method}`);
|
|
131
196
|
return { body: rpcError(id, -32601, `Method not found: ${req.method}`) };
|