@tuzelko/opencode-delayed-message 0.1.1 → 0.1.3

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
 
@@ -83,9 +85,13 @@ Above the composer, while a message is pending:
83
85
  ⏰ Scheduled · 10:18
84
86
  ```
85
87
 
86
- Click it for the list: time, relative countdown, a preview of the text, and `×` to cancel.
87
- The pill disappears when the message is delivered or cancelled. The terminal UI has no pill —
88
- there the transcript note is the only trace.
88
+ Click it for the list: time, relative countdown, a preview of the text, and `✎` to edit
89
+ or `×` to cancel. Edit cancels the pending job and puts the whole command back into the
90
+ composer — `/delayed-message`, the scheduled time, and the full text — with the cursor in
91
+ the prompt. Adjust anything and submit to reschedule. The pill disappears when the
92
+ message is delivered or cancelled.
93
+
94
+ The terminal UI has no pill — there the transcript note is the only trace.
89
95
 
90
96
  The pill follows the app language: English, Russian, German, French, and Chinese are
91
97
  included. Transcript notes are English, because they are written for the model.
@@ -96,6 +102,13 @@ Jobs are kept in the plugin's storage, not only in memory. After `opencode reloa
96
102
  service restart, timers are re-armed when the plugin loads for that directory. If the
97
103
  service is down at the scheduled moment, the message is delivered on the next start.
98
104
 
105
+ OpenCode unloads a directory's services after 60 minutes without activity (the
106
+ `experimental.location_idle_timeout` setting), and plugin timers die with them. In the
107
+ desktop app that no longer delays a message: the app's main process keeps its own timer,
108
+ persists it across app restarts, and asks the server to deliver at the scheduled moment,
109
+ which wakes the directory if it was unloaded. Without the desktop app, delivery still
110
+ waits for the next time that directory loads.
111
+
99
112
  ## 🧠 How it works
100
113
 
101
114
  - `ctx.command.transform` registers `/delayed-message`; the executor gets the raw text
@@ -104,8 +117,11 @@ service is down at the scheduled moment, the message is delivered on the next st
104
117
  24.8-day timer cap.
105
118
  - On fire: `ctx.session.prompt({ sessionID, text })` — a real user prompt, so the session
106
119
  wakes.
107
- - The GUI talks to the server through an RPC: `pending`, `cancel`, and the events
108
- `scheduled`, `fired`, `cancelled`.
120
+ - The GUI talks to the server through an RPC: `pending`, `cancel`, `edit`, `deliver`, and
121
+ the events `scheduled`, `fired`, `cancelled`.
122
+ - The GUI main process mirrors `scheduled` jobs into its own storage and arms Node timers.
123
+ When one is due it calls `deliver` with the job's directory, which boots the location
124
+ and delivers the message even though the server-side timer is gone.
109
125
 
110
126
  ## 🛠 Development
111
127
 
@@ -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}},edit:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`},text:{type:`string`},fireAt:{type:`number`}},required:[`ok`],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-action>✎</button><button class=dm-action>×`),s=2**31-1,c=e=>String(e).padStart(2,`0`),l=e=>{let t=new Date(e),n=`${t.getFullYear()}-${c(t.getMonth()+1)}-${c(t.getDate())}`,r=`${c(t.getHours())}:${c(t.getMinutes())}`;return t.getSeconds()===0?`${n}T${r}`:`${n}T${r}:${c(t.getSeconds())}`},u=`
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); }
@@ -6,6 +6,5 @@ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{val
6
6
  .dm-when { display: flex; align-items: baseline; gap: 8px; font-variant-numeric: tabular-nums; }
7
7
  .dm-left { opacity: .55; font-size: 12px; white-space: nowrap; }
8
8
  .dm-text { opacity: .72; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
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
- .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;
9
+ .dm-action { 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
+ .dm-action:hover { opacity: 1; background: color-mix(in srgb, currentColor 14%, transparent); }`,d=r.Plugin.define({i18n:{en:{"pill.one":`Scheduled · {{time}}`,"pill.many":`Scheduled: {{count}}`,"menu.title":`Scheduled messages`,"row.cancel":`Cancel`,"row.edit":`Edit`,"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":`Отменить`,"row.edit":`Изменить`,"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`,"row.edit":`Bearbeiten`,"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`,"row.edit":`Modifier`,"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":`取消`,"row.edit":`编辑`,"left.now":`现在`,"left.days":`还有 {{days}} 天 {{hours}} 小时`,"left.hours":`还有 {{hours}} 小时 {{minutes}} 分钟`,"left.minutes":`还有 {{minutes}} 分钟`}},setup(n){let[s,c]=(0,t.createSignal)({}),d=(e,t)=>c(n=>({...n,[e]:[...(n[e]??[]).filter(e=>e.id!==t.id),t].sort((e,t)=>e.fireAt-t.fireAt)})),f=(e,t)=>c(n=>({...n,[e]:(n[e]??[]).filter(e=>e.id!==t)})),p=e=>new Date(e).toLocaleTimeString(n.locale.locale(),{hour:`2-digit`,minute:`2-digit`}),m=e=>new Date(e).toLocaleDateString(n.locale.locale(),{day:`2-digit`,month:`2-digit`}),h=e=>{let t=new Date(e),n=new Date,r=p(e);return t.toDateString()===n.toDateString()?r:`${m(e)} ${r}`},g=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})},_=n.client.rpc(i);_.events.on(`scheduled`,e=>{d(e.data.sessionID,{id:e.data.id,fireAt:e.data.fireAt,preview:e.data.preview})}),_.events.on(`fired`,e=>{f(e.data.sessionID,e.data.id)}),_.events.on(`cancelled`,e=>{f(e.data.sessionID,e.data.id)});let v=async(e,t)=>{try{let{ok:n}=await _.cancel({sessionID:e,id:t});n&&f(e,t)}catch{}};(0,r.createKeyed)(()=>n.sessions.current()?.id,e=>{_.pending({sessionID:e}).then(({jobs:t})=>c(n=>({...n,[e]:t}))).catch(()=>{})}),n.add(r.Style,u),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:p(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,u=c.nextSibling,d=s.nextSibling,p=a.nextSibling,m=p.nextSibling;return(0,e.insert)(c,()=>h(t.fireAt)),(0,e.insert)(u,()=>g(t.fireAt)),(0,e.insert)(d,()=>t.preview),p.$$click=()=>{_.edit({sessionID:r.session.id,id:t.id}).then(({ok:e,text:n,fireAt:i})=>{e&&n!==void 0&&i!==void 0&&(f(r.session.id,t.id),r.screen.composer.setText(`/delayed-message ${l(i)} ${n}`),r.close(),r.screen.composer.focus())}).catch(()=>{})},m.$$click=()=>void v(r.session.id,t.id),(0,e.effect)(t=>{var r=n.t(`row.edit`),i=n.t(`row.edit`),a=n.t(`row.cancel`),o=n.t(`row.cancel`);return r!==t.e&&(0,e.setAttribute)(p,`title`,t.e=r),i!==t.t&&(0,e.setAttribute)(p,`aria-label`,t.t=i),a!==t.a&&(0,e.setAttribute)(m,`title`,t.a=a),o!==t.o&&(0,e.setAttribute)(m,`aria-label`,t.o=o),t},{e:void 0,t:void 0,a:void 0,o: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=d;
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,34 +87,58 @@ 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
+ return { ok: (await withdraw(sessionID, id)) !== undefined }
91
+ },
92
+ edit: async (input) => {
93
+ const { sessionID, id } = input as { sessionID: string; id: string }
94
+ const job = await withdraw(sessionID, id)
95
+ if (!job) return { ok: false }
96
+ return { ok: true, text: job.message, fireAt: job.fireAt }
97
+ },
98
+ deliver: async (input) => {
99
+ const { sessionID, id } = input as { sessionID: string; id: string }
100
+ if (claimed.has(id)) return { status: "delivered" }
101
+ if (delivering.has(id)) return { status: "busy" }
87
102
  const stored = (await ctx.storage.get(key(id))) as Job | undefined
88
- if (!stored || stored.sessionID !== sessionID) return { ok: false }
89
- claimed.add(id)
90
- const timer = timers.get(id)
91
- if (timer) {
92
- clearTimeout(timer)
93
- timers.delete(id)
94
- }
95
- await ctx.storage.remove(key(id))
96
- await rpc.events.emit("cancelled", { sessionID, id }).catch(() => {})
97
- return { ok: true }
103
+ if (!stored || stored.sessionID !== sessionID) return { status: "missing" }
104
+ return { status: await deliver(stored) }
98
105
  },
99
106
  })
100
107
 
101
- const deliver = async (job: Job) => {
102
- timers.delete(job.id)
103
- if (claimed.has(job.id)) return
104
- claimed.add(job.id)
108
+ const withdraw = async (sessionID: string, id: string): Promise<Job | undefined> => {
109
+ if (claimed.has(id) || delivering.has(id)) return undefined
110
+ const stored = (await ctx.storage.get(key(id))) as Job | undefined
111
+ if (!stored || stored.sessionID !== sessionID) return undefined
112
+ claimed.add(id)
113
+ const timer = timers.get(id)
114
+ if (timer) {
115
+ clearTimeout(timer)
116
+ timers.delete(id)
117
+ }
118
+ await ctx.storage.remove(key(id))
119
+ await rpc.events.emit("cancelled", { sessionID, id }).catch(() => {})
120
+ return stored
121
+ }
122
+
123
+ const deliver = async (job: Job): Promise<Delivery> => {
124
+ if (claimed.has(job.id)) return "delivered"
125
+ if (delivering.has(job.id)) return "busy"
126
+ delivering.add(job.id)
105
127
  try {
106
- if (!(await ctx.storage.get(key(job.id)))) return
107
- await ctx.storage.remove(key(job.id))
108
128
  await ctx.session.prompt({ sessionID: job.sessionID, text: job.message })
109
129
  } catch (error) {
110
130
  console.error(`[delayed-message] delivery failed for ${job.id}:`, error)
131
+ return "failed"
111
132
  } finally {
112
- await rpc.events.emit("fired", { sessionID: job.sessionID, id: job.id }).catch(() => {})
133
+ delivering.delete(job.id)
113
134
  }
135
+ // Claim only after the prompt landed: a failure keeps the job in storage
136
+ // for the next boot or for the desktop main process to retry.
137
+ claimed.add(job.id)
138
+ timers.delete(job.id)
139
+ await ctx.storage.remove(key(job.id)).catch(() => {})
140
+ await rpc.events.emit("fired", { sessionID: job.sessionID, id: job.id }).catch(() => {})
141
+ return "delivered"
114
142
  }
115
143
 
116
144
  // setTimeout caps at MAX_TIMER, so long waits are split into hops.
@@ -185,6 +213,7 @@ export default Plugin.define({
185
213
  id: job.id,
186
214
  fireAt: at,
187
215
  preview: previewOf(message),
216
+ directory: job.directory,
188
217
  }).catch(() => {})
189
218
  await note(sessionID, `Delayed message scheduled for ${stamp(at)} (in ${humanize(at - now)}).`)
190
219
  } 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.3",
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,49 @@ 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
+ },
73
+ // Cancels the job and hands its full text back so the pill can put the
74
+ // command into the composer for editing.
75
+ edit: {
76
+ input: {
77
+ type: "object",
78
+ properties: {
79
+ sessionID: { type: "string" },
80
+ id: { type: "string" },
81
+ },
82
+ required: ["sessionID", "id"],
83
+ additionalProperties: false,
84
+ },
85
+ output: {
86
+ type: "object",
87
+ properties: {
88
+ ok: { type: "boolean" },
89
+ text: { type: "string" },
90
+ fireAt: { type: "number" },
91
+ },
92
+ required: ["ok"],
93
+ additionalProperties: false,
94
+ },
95
+ },
53
96
  },
54
97
  events: {
55
98
  scheduled: {
@@ -60,8 +103,9 @@ export const Delayed = Rpc.define({
60
103
  id: { type: "string" },
61
104
  fireAt: { type: "number" },
62
105
  preview: { type: "string" },
106
+ directory: { type: "string" },
63
107
  },
64
- required: ["sessionID", "id", "fireAt", "preview"],
108
+ required: ["sessionID", "id", "fireAt", "preview", "directory"],
65
109
  additionalProperties: false,
66
110
  },
67
111
  },