@tuzelko/opencode-delayed-message 0.1.0 → 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 +14 -2
- package/gui-experimental.cjs +2 -2
- package/index.ts +27 -8
- package/package.json +1 -1
- package/rpc.ts +22 -1
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
|
|
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
|
|
package/gui-experimental.cjs
CHANGED
|
@@ -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
|
|
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
|
-
`,
|
|
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
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
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
|
-
|
|
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
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
|
},
|