@tuzelko/opencode-delayed-message 0.1.1 → 0.1.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 CHANGED
@@ -28,6 +28,8 @@ the text into the conversation early.
28
28
  - 💾 **Durable** — jobs survive a service restart; timers re-arm from storage.
29
29
  - 🪟 **Desktop pill** — pending count and time above the composer, with a preview and a
30
30
  cancel button.
31
+ - ⏱️ **On time in the desktop app** — the app's main process keeps the clock, so an idle
32
+ window or an evicted location does not delay delivery.
31
33
  - 🤫 **Zero model turns on schedule** — a successful schedule does not wake the model.
32
34
  - 🌍 **Localized UI** — English, Russian, German, French, Chinese.
33
35
 
@@ -96,6 +98,13 @@ Jobs are kept in the plugin's storage, not only in memory. After `opencode reloa
96
98
  service restart, timers are re-armed when the plugin loads for that directory. If the
97
99
  service is down at the scheduled moment, the message is delivered on the next start.
98
100
 
101
+ OpenCode unloads a directory's services after 60 minutes without activity (the
102
+ `experimental.location_idle_timeout` setting), and plugin timers die with them. In the
103
+ desktop app that no longer delays a message: the app's main process keeps its own timer,
104
+ persists it across app restarts, and asks the server to deliver at the scheduled moment,
105
+ which wakes the directory if it was unloaded. Without the desktop app, delivery still
106
+ waits for the next time that directory loads.
107
+
99
108
  ## 🧠 How it works
100
109
 
101
110
  - `ctx.command.transform` registers `/delayed-message`; the executor gets the raw text
@@ -104,8 +113,11 @@ service is down at the scheduled moment, the message is delivered on the next st
104
113
  24.8-day timer cap.
105
114
  - On fire: `ctx.session.prompt({ sessionID, text })` — a real user prompt, so the session
106
115
  wakes.
107
- - The GUI talks to the server through an RPC: `pending`, `cancel`, and the events
108
- `scheduled`, `fired`, `cancelled`.
116
+ - The GUI talks to the server through an RPC: `pending`, `cancel`, `deliver`, and the
117
+ events `scheduled`, `fired`, `cancelled`.
118
+ - The GUI main process mirrors `scheduled` jobs into its own storage and arms Node timers.
119
+ When one is due it calls `deliver` with the job's directory, which boots the location
120
+ and delivers the message even though the server-side timer is gone.
109
121
 
110
122
  ## 🛠 Development
111
123
 
@@ -1,4 +1,4 @@
1
- Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("solid-js"),n=require("@opencode/plugin/gui");var r=require("@opencode/plugin/rpc").Rpc.define({id:`delayed-message`,methods:{pending:{input:{type:`object`,properties:{sessionID:{type:`string`}},required:[`sessionID`],additionalProperties:!1},output:{type:`object`,properties:{jobs:{type:`array`,items:{type:`object`,properties:{id:{type:`string`},fireAt:{type:`number`},preview:{type:`string`}},required:[`id`,`fireAt`,`preview`],additionalProperties:!1}}},required:[`jobs`],additionalProperties:!1}},cancel:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`}},required:[`ok`],additionalProperties:!1}}},events:{scheduled:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`},fireAt:{type:`number`},preview:{type:`string`}},required:[`sessionID`,`id`,`fireAt`,`preview`],additionalProperties:!1}},fired:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1}},cancelled:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1}}}}),i=(0,e.template)(`<div class=dm>`),a=(0,e.template)(`<div class=dm-row><div class=dm-body><div class=dm-when><span></span><span class=dm-left></span></div><div class=dm-text></div></div><button class=dm-x>×`),o=`
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("solid-js"),n=require("effect"),r=require("@opencode/plugin/gui");var i=require("@opencode/plugin/rpc").Rpc.define({id:`delayed-message`,methods:{pending:{input:{type:`object`,properties:{sessionID:{type:`string`}},required:[`sessionID`],additionalProperties:!1},output:{type:`object`,properties:{jobs:{type:`array`,items:{type:`object`,properties:{id:{type:`string`},fireAt:{type:`number`},preview:{type:`string`}},required:[`id`,`fireAt`,`preview`],additionalProperties:!1}}},required:[`jobs`],additionalProperties:!1}},cancel:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`}},required:[`ok`],additionalProperties:!1}},deliver:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{status:{type:`string`}},required:[`status`],additionalProperties:!1}}},events:{scheduled:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`},fireAt:{type:`number`},preview:{type:`string`},directory:{type:`string`}},required:[`sessionID`,`id`,`fireAt`,`preview`,`directory`],additionalProperties:!1}},fired:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1}},cancelled:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1}}}}),a=(0,e.template)(`<div class=dm>`),o=(0,e.template)(`<div class=dm-row><div class=dm-body><div class=dm-when><span></span><span class=dm-left></span></div><div class=dm-text></div></div><button class=dm-x>×`),s=2**31-1,c=`
2
2
  .dm { display: flex; flex-direction: column; min-width: 280px; max-width: 440px; }
3
3
  .dm-row { display: flex; align-items: center; gap: 8px; padding: 8px 2px; }
4
4
  .dm-row + .dm-row { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
@@ -8,4 +8,4 @@ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{val
8
8
  .dm-text { opacity: .72; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
9
9
  .dm-x { flex: none; width: 22px; height: 22px; border: 0; border-radius: 6px; background: transparent; color: inherit; opacity: .45; cursor: pointer; font: inherit; line-height: 1; }
10
10
  .dm-x:hover { opacity: 1; background: color-mix(in srgb, currentColor 14%, transparent); }
11
- `,s=n.Plugin.define({i18n:{en:{"pill.one":`Scheduled · {{time}}`,"pill.many":`Scheduled: {{count}}`,"menu.title":`Scheduled messages`,"row.cancel":`Cancel`,"left.now":`now`,"left.days":`in {{days}}d {{hours}}h`,"left.hours":`in {{hours}}h {{minutes}}m`,"left.minutes":`in {{minutes}}m`},ru:{"pill.one":`Отложенное · {{time}}`,"pill.many":`Отложенных: {{count}}`,"menu.title":`Отложенные сообщения`,"row.cancel":`Отменить`,"left.now":`сейчас`,"left.days":`через {{days}} д {{hours}} ч`,"left.hours":`через {{hours}} ч {{minutes}} мин`,"left.minutes":`через {{minutes}} мин`},de:{"pill.one":`Geplant · {{time}}`,"pill.many":`Geplant: {{count}}`,"menu.title":`Geplante Nachrichten`,"row.cancel":`Abbrechen`,"left.now":`jetzt`,"left.days":`in {{days}} T {{hours}} Std.`,"left.hours":`in {{hours}} Std. {{minutes}} Min.`,"left.minutes":`in {{minutes}} Min.`},fr:{"pill.one":`Programmé · {{time}}`,"pill.many":`Programmés : {{count}}`,"menu.title":`Messages programmés`,"row.cancel":`Annuler`,"left.now":`maintenant`,"left.days":`dans {{days}} j {{hours}} h`,"left.hours":`dans {{hours}} h {{minutes}} min`,"left.minutes":`dans {{minutes}} min`},zh:{"pill.one":`已安排 · {{time}}`,"pill.many":`已安排:{{count}}`,"menu.title":`已安排的消息`,"row.cancel":`取消`,"left.now":`现在`,"left.days":`还有 {{days}} 天 {{hours}} 小时`,"left.hours":`还有 {{hours}} 小时 {{minutes}} 分钟`,"left.minutes":`还有 {{minutes}} 分钟`}},setup(s){let[c,l]=(0,t.createSignal)({}),u=(e,t)=>l(n=>({...n,[e]:[...(n[e]??[]).filter(e=>e.id!==t.id),t].sort((e,t)=>e.fireAt-t.fireAt)})),d=(e,t)=>l(n=>({...n,[e]:(n[e]??[]).filter(e=>e.id!==t)})),f=e=>new Date(e).toLocaleTimeString(s.locale.locale(),{hour:`2-digit`,minute:`2-digit`}),p=e=>new Date(e).toLocaleDateString(s.locale.locale(),{day:`2-digit`,month:`2-digit`}),m=e=>{let t=new Date(e),n=new Date,r=f(e);return t.toDateString()===n.toDateString()?r:`${p(e)} ${r}`},h=e=>{let t=e-Date.now();if(t<=0)return s.t(`left.now`);let n=Math.ceil(t/6e4),r=Math.floor(n/60),i=Math.floor(r/24);return i>0?s.t(`left.days`,{days:i,hours:r%24}):r>0?s.t(`left.hours`,{hours:r,minutes:n%60}):s.t(`left.minutes`,{minutes:n})},g=s.client.rpc(r);g.events.on(`scheduled`,e=>{u(e.data.sessionID,{id:e.data.id,fireAt:e.data.fireAt,preview:e.data.preview})}),g.events.on(`fired`,e=>{d(e.data.sessionID,e.data.id)}),g.events.on(`cancelled`,e=>{d(e.data.sessionID,e.data.id)});let _=async(e,t)=>{try{let{ok:n}=await g.cancel({sessionID:e,id:t});n&&d(e,t)}catch{}};(0,n.createKeyed)(()=>s.sessions.current()?.id,e=>{g.pending({sessionID:e}).then(({jobs:t})=>l(n=>({...n,[e]:t}))).catch(()=>{})}),s.add(n.Style,o),s.add(n.ComposerPill,{id:`pending`,show(e){let t=c()[e.session.id]??[];if(t.length!==0)return{label:t.length===1?s.t(`pill.one`,{time:f(t[0].fireAt)}):s.t(`pill.many`,{count:t.length}),icon:`notifications`,title:s.t(`menu.title`)}},menu:n=>(()=>{var r=i();return(0,e.insert)(r,(0,e.createComponent)(t.For,{get each(){return c()[n.session.id]??[]},children:t=>(()=>{var r=a(),i=r.firstChild,o=i.firstChild,c=o.firstChild,l=c.nextSibling,u=o.nextSibling,d=i.nextSibling;return(0,e.insert)(c,()=>m(t.fireAt)),(0,e.insert)(l,()=>h(t.fireAt)),(0,e.insert)(u,()=>t.preview),d.$$click=()=>void _(n.session.id,t.id),(0,e.effect)(t=>{var n=s.t(`row.cancel`),r=s.t(`row.cancel`);return n!==t.e&&(0,e.setAttribute)(d,`title`,t.e=n),r!==t.t&&(0,e.setAttribute)(d,`aria-label`,t.t=r),t},{e:void 0,t:void 0}),r})()})),r})()})}});(0,e.delegateEvents)([`click`]),exports.default=s;
11
+ `,l=r.Plugin.define({i18n:{en:{"pill.one":`Scheduled · {{time}}`,"pill.many":`Scheduled: {{count}}`,"menu.title":`Scheduled messages`,"row.cancel":`Cancel`,"left.now":`now`,"left.days":`in {{days}}d {{hours}}h`,"left.hours":`in {{hours}}h {{minutes}}m`,"left.minutes":`in {{minutes}}m`},ru:{"pill.one":`Отложенное · {{time}}`,"pill.many":`Отложенных: {{count}}`,"menu.title":`Отложенные сообщения`,"row.cancel":`Отменить`,"left.now":`сейчас`,"left.days":`через {{days}} д {{hours}} ч`,"left.hours":`через {{hours}} ч {{minutes}} мин`,"left.minutes":`через {{minutes}} мин`},de:{"pill.one":`Geplant · {{time}}`,"pill.many":`Geplant: {{count}}`,"menu.title":`Geplante Nachrichten`,"row.cancel":`Abbrechen`,"left.now":`jetzt`,"left.days":`in {{days}} T {{hours}} Std.`,"left.hours":`in {{hours}} Std. {{minutes}} Min.`,"left.minutes":`in {{minutes}} Min.`},fr:{"pill.one":`Programmé · {{time}}`,"pill.many":`Programmés : {{count}}`,"menu.title":`Messages programmés`,"row.cancel":`Annuler`,"left.now":`maintenant`,"left.days":`dans {{days}} j {{hours}} h`,"left.hours":`dans {{hours}} h {{minutes}} min`,"left.minutes":`dans {{minutes}} min`},zh:{"pill.one":`已安排 · {{time}}`,"pill.many":`已安排:{{count}}`,"menu.title":`已安排的消息`,"row.cancel":`取消`,"left.now":`现在`,"left.days":`还有 {{days}} 天 {{hours}} 小时`,"left.hours":`还有 {{hours}} 小时 {{minutes}} 分钟`,"left.minutes":`还有 {{minutes}} 分钟`}},setup(n){let[s,l]=(0,t.createSignal)({}),u=(e,t)=>l(n=>({...n,[e]:[...(n[e]??[]).filter(e=>e.id!==t.id),t].sort((e,t)=>e.fireAt-t.fireAt)})),d=(e,t)=>l(n=>({...n,[e]:(n[e]??[]).filter(e=>e.id!==t)})),f=e=>new Date(e).toLocaleTimeString(n.locale.locale(),{hour:`2-digit`,minute:`2-digit`}),p=e=>new Date(e).toLocaleDateString(n.locale.locale(),{day:`2-digit`,month:`2-digit`}),m=e=>{let t=new Date(e),n=new Date,r=f(e);return t.toDateString()===n.toDateString()?r:`${p(e)} ${r}`},h=e=>{let t=e-Date.now();if(t<=0)return n.t(`left.now`);let r=Math.ceil(t/6e4),i=Math.floor(r/60),a=Math.floor(i/24);return a>0?n.t(`left.days`,{days:a,hours:i%24}):i>0?n.t(`left.hours`,{hours:i,minutes:r%60}):n.t(`left.minutes`,{minutes:r})},g=n.client.rpc(i);g.events.on(`scheduled`,e=>{u(e.data.sessionID,{id:e.data.id,fireAt:e.data.fireAt,preview:e.data.preview})}),g.events.on(`fired`,e=>{d(e.data.sessionID,e.data.id)}),g.events.on(`cancelled`,e=>{d(e.data.sessionID,e.data.id)});let _=async(e,t)=>{try{let{ok:n}=await g.cancel({sessionID:e,id:t});n&&d(e,t)}catch{}};(0,r.createKeyed)(()=>n.sessions.current()?.id,e=>{g.pending({sessionID:e}).then(({jobs:t})=>l(n=>({...n,[e]:t}))).catch(()=>{})}),n.add(r.Style,c),n.add(r.ComposerPill,{id:`pending`,show(e){let t=s()[e.session.id]??[];if(t.length!==0)return{label:t.length===1?n.t(`pill.one`,{time:f(t[0].fireAt)}):n.t(`pill.many`,{count:t.length}),icon:`notifications`,title:n.t(`menu.title`)}},menu:r=>(()=>{var i=a();return(0,e.insert)(i,(0,e.createComponent)(t.For,{get each(){return s()[r.session.id]??[]},children:t=>(()=>{var i=o(),a=i.firstChild,s=a.firstChild,c=s.firstChild,l=c.nextSibling,u=s.nextSibling,d=a.nextSibling;return(0,e.insert)(c,()=>m(t.fireAt)),(0,e.insert)(l,()=>h(t.fireAt)),(0,e.insert)(u,()=>t.preview),d.$$click=()=>void _(r.session.id,t.id),(0,e.effect)(t=>{var r=n.t(`row.cancel`),i=n.t(`row.cancel`);return r!==t.e&&(0,e.setAttribute)(d,`title`,t.e=r),i!==t.t&&(0,e.setAttribute)(d,`aria-label`,t.t=i),t},{e:void 0,t:void 0}),i})()})),i})()})},main(e){let t=n.Schema.Struct({id:n.Schema.String,sessionID:n.Schema.String,directory:n.Schema.String,fireAt:n.Schema.Number}),r=e.storage.store(`pending`,{schema:n.Schema.Array(t),initial:[]}),a=new Map,o=new Map,c=e=>{let t=a.get(e);t&&clearTimeout(t),a.delete(e)},l=e=>{c(e),o.delete(e),r.value.some(t=>t.id===e)&&r.set(r.value.filter(t=>t.id!==e))},u=e=>{c(e.id);let t=e.fireAt-Date.now();t>s-1?a.set(e.id,setTimeout(()=>u(e),s-1)):a.set(e.id,setTimeout(()=>void d(e),Math.max(0,t)))},d=async t=>{if(c(t.id),Date.now()<t.fireAt)u(t);else try{let{status:n}=await e.client.rpc(i).deliver({sessionID:t.sessionID,id:t.id},{location:{directory:t.directory}});if(n===`delivered`||n===`missing`){o.delete(t.id),l(t.id);return}if(n===`failed`){let e=(o.get(t.id)??0)+1;if(e>=5){o.delete(t.id),l(t.id);return}o.set(t.id,e)}a.set(t.id,setTimeout(()=>void d(t),n===`busy`?2e3:3e4))}catch{a.set(t.id,setTimeout(()=>void d(t),15e3))}},f=e=>{r.set([...r.value.filter(t=>t.id!==e.id),e]),u(e)},p=e=>{if(e.type===`rpc.delayed-message.scheduled`){let t=e.data;if(!t?.id||!t.sessionID||!t.fireAt||!t.directory)return;f({id:t.id,sessionID:t.sessionID,directory:t.directory,fireAt:t.fireAt})}else if(e.type===`rpc.delayed-message.fired`||e.type===`rpc.delayed-message.cancelled`){let t=e.data;t?.id&&l(t.id)}};for(let e of r.value)u(e);(async()=>{for(;!e.signal.aborted;){try{for await(let t of e.client.event.subscribe({signal:e.signal}))p(t)}catch{}if(e.signal.aborted)break;await new Promise(e=>setTimeout(e,3e3))}})(),e.onCleanup(()=>{for(let e of a.values())clearTimeout(e);a.clear()})}});(0,e.delegateEvents)([`click`]),exports.default=l;
package/index.ts CHANGED
@@ -20,6 +20,10 @@ const UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 8
20
20
 
21
21
  // Setup can run more than once per directory; only the first timer may deliver.
22
22
  const claimed = new Set<string>()
23
+ // Guards concurrent deliveries of one job while the prompt is in flight.
24
+ const delivering = new Set<string>()
25
+
26
+ type Delivery = "delivered" | "busy" | "failed"
23
27
 
24
28
  const key = (id: string) => `pending/${id}`
25
29
 
@@ -83,7 +87,7 @@ export default Plugin.define({
83
87
  },
84
88
  cancel: async (input) => {
85
89
  const { sessionID, id } = input as { sessionID: string; id: string }
86
- if (claimed.has(id)) return { ok: false }
90
+ if (claimed.has(id) || delivering.has(id)) return { ok: false }
87
91
  const stored = (await ctx.storage.get(key(id))) as Job | undefined
88
92
  if (!stored || stored.sessionID !== sessionID) return { ok: false }
89
93
  claimed.add(id)
@@ -96,21 +100,35 @@ export default Plugin.define({
96
100
  await rpc.events.emit("cancelled", { sessionID, id }).catch(() => {})
97
101
  return { ok: true }
98
102
  },
103
+ deliver: async (input) => {
104
+ const { sessionID, id } = input as { sessionID: string; id: string }
105
+ if (claimed.has(id)) return { status: "delivered" }
106
+ if (delivering.has(id)) return { status: "busy" }
107
+ const stored = (await ctx.storage.get(key(id))) as Job | undefined
108
+ if (!stored || stored.sessionID !== sessionID) return { status: "missing" }
109
+ return { status: await deliver(stored) }
110
+ },
99
111
  })
100
112
 
101
- const deliver = async (job: Job) => {
102
- timers.delete(job.id)
103
- if (claimed.has(job.id)) return
104
- claimed.add(job.id)
113
+ const deliver = async (job: Job): Promise<Delivery> => {
114
+ if (claimed.has(job.id)) return "delivered"
115
+ if (delivering.has(job.id)) return "busy"
116
+ delivering.add(job.id)
105
117
  try {
106
- if (!(await ctx.storage.get(key(job.id)))) return
107
- await ctx.storage.remove(key(job.id))
108
118
  await ctx.session.prompt({ sessionID: job.sessionID, text: job.message })
109
119
  } catch (error) {
110
120
  console.error(`[delayed-message] delivery failed for ${job.id}:`, error)
121
+ return "failed"
111
122
  } finally {
112
- await rpc.events.emit("fired", { sessionID: job.sessionID, id: job.id }).catch(() => {})
123
+ delivering.delete(job.id)
113
124
  }
125
+ // Claim only after the prompt landed: a failure keeps the job in storage
126
+ // for the next boot or for the desktop main process to retry.
127
+ claimed.add(job.id)
128
+ timers.delete(job.id)
129
+ await ctx.storage.remove(key(job.id)).catch(() => {})
130
+ await rpc.events.emit("fired", { sessionID: job.sessionID, id: job.id }).catch(() => {})
131
+ return "delivered"
114
132
  }
115
133
 
116
134
  // setTimeout caps at MAX_TIMER, so long waits are split into hops.
@@ -185,6 +203,7 @@ export default Plugin.define({
185
203
  id: job.id,
186
204
  fireAt: at,
187
205
  preview: previewOf(message),
206
+ directory: job.directory,
188
207
  }).catch(() => {})
189
208
  await note(sessionID, `Delayed message scheduled for ${stamp(at)} (in ${humanize(at - now)}).`)
190
209
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuzelko/opencode-delayed-message",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Schedule a message that wakes an OpenCode session later, without showing it to the model first.",
5
5
  "keywords": [
6
6
  "opencode",
package/rpc.ts CHANGED
@@ -50,6 +50,26 @@ export const Delayed = Rpc.define({
50
50
  additionalProperties: false,
51
51
  },
52
52
  },
53
+ // The desktop app's main process owns a timer outside the location, so it
54
+ // can ask for delivery after the location that scheduled the job was
55
+ // evicted. Status is "delivered" | "missing" | "busy" | "failed".
56
+ deliver: {
57
+ input: {
58
+ type: "object",
59
+ properties: {
60
+ sessionID: { type: "string" },
61
+ id: { type: "string" },
62
+ },
63
+ required: ["sessionID", "id"],
64
+ additionalProperties: false,
65
+ },
66
+ output: {
67
+ type: "object",
68
+ properties: { status: { type: "string" } },
69
+ required: ["status"],
70
+ additionalProperties: false,
71
+ },
72
+ },
53
73
  },
54
74
  events: {
55
75
  scheduled: {
@@ -60,8 +80,9 @@ export const Delayed = Rpc.define({
60
80
  id: { type: "string" },
61
81
  fireAt: { type: "number" },
62
82
  preview: { type: "string" },
83
+ directory: { type: "string" },
63
84
  },
64
- required: ["sessionID", "id", "fireAt", "preview"],
85
+ required: ["sessionID", "id", "fireAt", "preview", "directory"],
65
86
  additionalProperties: false,
66
87
  },
67
88
  },