@tuzelko/opencode-delayed-message 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Frost
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # ⏰ opencode-delayed-message
4
+
5
+ **Schedule a message that wakes the session later — without putting it in the prompt now.**
6
+
7
+ [![npm](https://img.shields.io/npm/v/@tuzelko/opencode-delayed-message?style=flat-square&color=CB3837&logo=npm)](https://www.npmjs.com/package/@tuzelko/opencode-delayed-message)
8
+ [![downloads](https://img.shields.io/npm/dm/@tuzelko/opencode-delayed-message?style=flat-square)](https://www.npmjs.com/package/@tuzelko/opencode-delayed-message)
9
+ [![license](https://img.shields.io/github/license/TuzelKO/opencode-delayed-message?style=flat-square)](./LICENSE)
10
+ [![OpenCode V2](https://img.shields.io/badge/OpenCode-V2-5B8DEF?style=flat-square)](https://opencode.ai)
11
+ [![desktop UI](https://img.shields.io/badge/UI-desktop-8B5CF6?style=flat-square)](https://opencode.ai)
12
+
13
+ </div>
14
+
15
+ ---
16
+
17
+ `/delayed-message` schedules a message for a future time and delivers it as a normal user
18
+ message. The trick: the text never enters the model context at scheduling time. The model
19
+ sees it only when it arrives.
20
+
21
+ Built for "remind me at 3:00" without trusting the model to remember — and without leaking
22
+ the text into the conversation early.
23
+
24
+ ## ✨ Features
25
+
26
+ - 🕰️ **Say it your way** — `HH:MM`, an absolute date, or a relative duration.
27
+ - 🔒 **Private until delivery** — the text lives in plugin storage, not in the prompt.
28
+ - 💾 **Durable** — jobs survive a service restart; timers re-arm from storage.
29
+ - 🪟 **Desktop pill** — pending count and time above the composer, with a preview and a
30
+ cancel button.
31
+ - 🤫 **Zero model turns on schedule** — a successful schedule does not wake the model.
32
+ - 🌍 **Localized UI** — English, Russian, German, French, Chinese.
33
+
34
+ ## 📦 Install
35
+
36
+ ```sh
37
+ opencode plugin add @tuzelko/opencode-delayed-message
38
+ ```
39
+
40
+ Or straight from Git:
41
+
42
+ ```sh
43
+ opencode plugin add github:TuzelKO/opencode-delayed-message
44
+ ```
45
+
46
+ Requires OpenCode V2. The pill is part of the desktop app; the command itself works anywhere.
47
+
48
+ ## 🚀 Usage
49
+
50
+ ```text
51
+ /delayed-message 03:00 call mom
52
+ /delayed-message 2026-11-05 03:00 check the deploy
53
+ /delayed-message +30m stretch
54
+ ```
55
+
56
+ | Time | Meaning |
57
+ | --- | --- |
58
+ | `HH:MM` / `H:MM` | the next occurrence; if it already passed today, tomorrow |
59
+ | `YYYY-MM-DD HH:MM` | absolute local time; a past time is refused |
60
+ | `YYYY-MM-DDTHH:MM` | same, ISO form without the space |
61
+ | `+30s`, `+30m`, `+2h`, `+1d` | relative to now |
62
+
63
+ The first token is the time; everything after it is the message, taken verbatim.
64
+
65
+ ## 🔒 Privacy
66
+
67
+ The command is a native plugin command: its executor receives the raw arguments and stores
68
+ the message in OpenCode's plugin storage. It is not submitted as a prompt, so the model
69
+ cannot read it at scheduling time.
70
+
71
+ At the scheduled moment the plugin calls `session.prompt` with the stored text, and it
72
+ arrives as a normal user message.
73
+
74
+ The honest caveat: the text is stored on disk in OpenCode's database. The model does not get
75
+ it through the prompt, but an agent with shell access to the machine could read the storage.
76
+ Treat this as a prompt-level guarantee, not encryption.
77
+
78
+ ## 🪟 The desktop pill
79
+
80
+ Above the composer, while a message is pending:
81
+
82
+ ```text
83
+ ⏰ Scheduled · 10:18
84
+ ```
85
+
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.
89
+
90
+ The pill follows the app language: English, Russian, German, French, and Chinese are
91
+ included. Transcript notes are English, because they are written for the model.
92
+
93
+ ## 💾 Durability
94
+
95
+ Jobs are kept in the plugin's storage, not only in memory. After `opencode reload` or a
96
+ service restart, timers are re-armed when the plugin loads for that directory. If the
97
+ service is down at the scheduled moment, the message is delivered on the next start.
98
+
99
+ ## 🧠 How it works
100
+
101
+ - `ctx.command.transform` registers `/delayed-message`; the executor gets the raw text
102
+ outside the prompt.
103
+ - The job goes to `ctx.storage` (`pending/<uuid>`) and a `setTimeout` chain, split at the
104
+ 24.8-day timer cap.
105
+ - On fire: `ctx.session.prompt({ sessionID, text })` — a real user prompt, so the session
106
+ wakes.
107
+ - The GUI talks to the server through an RPC: `pending`, `cancel`, and the events
108
+ `scheduled`, `fired`, `cancelled`.
109
+
110
+ ## 🛠 Development
111
+
112
+ ```sh
113
+ npm install
114
+ npm run build # emits gui-experimental.cjs
115
+ ```
116
+
117
+ Point OpenCode at the checkout while developing:
118
+
119
+ ```jsonc
120
+ {
121
+ "plugins": ["/path/to/opencode-delayed-message"]
122
+ }
123
+ ```
124
+
125
+ ## 📄 License
126
+
127
+ MIT © Eugene Frost
@@ -0,0 +1,11 @@
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=`
2
+ .dm { display: flex; flex-direction: column; min-width: 280px; max-width: 440px; }
3
+ .dm-row { display: flex; align-items: center; gap: 8px; padding: 8px 2px; }
4
+ .dm-row + .dm-row { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
5
+ .dm-body { flex: 1; min-width: 0; }
6
+ .dm-when { display: flex; align-items: baseline; gap: 8px; font-variant-numeric: tabular-nums; }
7
+ .dm-left { opacity: .55; font-size: 12px; white-space: nowrap; }
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;
package/index.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { Delayed } from "./rpc"
3
+
4
+ // /delayed-message <time> <message>
5
+ //
6
+ // The command is registered by the plugin, so the message never becomes a user
7
+ // prompt at scheduling time: it stays inside `execute` and the model sees it
8
+ // only when the timer delivers it as a real user prompt.
9
+
10
+ type Job = {
11
+ id: string
12
+ sessionID: string
13
+ message: string
14
+ fireAt: number
15
+ directory: string
16
+ }
17
+
18
+ const MAX_TIMER = 2 ** 31 - 1
19
+ const UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
20
+
21
+ // Setup can run more than once per directory; only the first timer may deliver.
22
+ const claimed = new Set<string>()
23
+
24
+ const key = (id: string) => `pending/${id}`
25
+
26
+ const resolveTime = (raw: string, now: number): number | string => {
27
+ const relative = /^\+(\d+)([smhd])$/.exec(raw)
28
+ if (relative) return now + Number(relative[1]) * UNIT_MS[relative[2]]
29
+
30
+ const clock = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(raw)
31
+ if (clock) {
32
+ const hours = Number(clock[1])
33
+ const minutes = Number(clock[2])
34
+ const seconds = Number(clock[3] ?? 0)
35
+ if (hours > 23 || minutes > 59 || seconds > 59) return `invalid clock time: ${raw}`
36
+ const at = new Date(now)
37
+ at.setHours(hours, minutes, seconds, 0)
38
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1)
39
+ return at.getTime()
40
+ }
41
+
42
+ const at = Date.parse(raw)
43
+ if (Number.isNaN(at)) return `cannot parse time: ${raw}`
44
+ if (at <= now) return `time is in the past: ${raw}`
45
+ return at
46
+ }
47
+
48
+ const stamp = (at: number) =>
49
+ `${new Date(at).toLocaleString("sv-SE", { hour12: false })} ${Intl.DateTimeFormat().resolvedOptions().timeZone}`
50
+
51
+ const humanize = (ms: number) => {
52
+ const seconds = Math.max(0, Math.round(ms / 1000))
53
+ const days = Math.floor(seconds / 86_400)
54
+ const hours = Math.floor((seconds % 86_400) / 3_600)
55
+ const minutes = Math.floor((seconds % 3_600) / 60)
56
+ if (days > 0) return `${days}d ${hours}h`
57
+ if (hours > 0) return `${hours}h ${minutes}m`
58
+ return `${minutes}m`
59
+ }
60
+
61
+ const previewOf = (message: string) => message.replace(/\s+/g, " ").trim().slice(0, 120)
62
+
63
+ export default Plugin.define({
64
+ id: "delayed-message",
65
+ async setup(ctx) {
66
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
67
+
68
+ const rpc = await ctx.rpc.register(Delayed, {
69
+ pending: async (input) => {
70
+ const { sessionID } = input as { sessionID: string }
71
+ const jobs: Array<{ id: string; fireAt: number; preview: string }> = []
72
+ let after: string | undefined
73
+ do {
74
+ const page = await ctx.storage.scan(after ? { prefix: "pending/", after } : { prefix: "pending/" })
75
+ for (const entry of page.entries) {
76
+ const job = entry.value as Job
77
+ if (job?.sessionID !== sessionID || !job.id || !job.fireAt) continue
78
+ jobs.push({ id: job.id, fireAt: job.fireAt, preview: previewOf(job.message) })
79
+ }
80
+ after = page.next
81
+ } while (after)
82
+ return { jobs }
83
+ },
84
+ cancel: async (input) => {
85
+ const { sessionID, id } = input as { sessionID: string; id: string }
86
+ if (claimed.has(id)) return { ok: false }
87
+ 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 }
98
+ },
99
+ })
100
+
101
+ const deliver = async (job: Job) => {
102
+ timers.delete(job.id)
103
+ if (claimed.has(job.id)) return
104
+ claimed.add(job.id)
105
+ try {
106
+ if (!(await ctx.storage.get(key(job.id)))) return
107
+ await ctx.storage.remove(key(job.id))
108
+ await ctx.session.prompt({ sessionID: job.sessionID, text: job.message })
109
+ } catch (error) {
110
+ console.error(`[delayed-message] delivery failed for ${job.id}:`, error)
111
+ } finally {
112
+ await rpc.events.emit("fired", { sessionID: job.sessionID, id: job.id }).catch(() => {})
113
+ }
114
+ }
115
+
116
+ // setTimeout caps at MAX_TIMER, so long waits are split into hops.
117
+ const arm = (job: Job) => {
118
+ const delay = job.fireAt - Date.now()
119
+ if (delay <= 0) {
120
+ void deliver(job)
121
+ return
122
+ }
123
+ const timer = setTimeout(() => {
124
+ timers.delete(job.id)
125
+ arm(job)
126
+ }, Math.min(delay, MAX_TIMER - 1))
127
+ timers.set(job.id, timer)
128
+ }
129
+
130
+ // The GUI pill is the immediate feedback; the note is only a transcript
131
+ // record, so it waits for the next turn instead of waking the model.
132
+ const note = (sessionID: string, text: string) =>
133
+ ctx.session.synthetic({ sessionID, text, resume: false }).catch(() => {})
134
+
135
+ // Errors have no pill to lean on, so they drain at once.
136
+ const alert = (sessionID: string, text: string) =>
137
+ ctx.session.synthetic({ sessionID, text, resume: true }).catch(() => {})
138
+
139
+ await ctx.command.transform((editor) => {
140
+ editor.add({
141
+ name: "delayed-message",
142
+ description: "Wake this session later with a message",
143
+ execute: async ({ sessionID, prompt }) => {
144
+ try {
145
+ const raw = prompt.text.trim()
146
+ const first = /^(\S+)(?:\s+|$)/.exec(raw)
147
+ const when = first?.[1] ?? ""
148
+ let rest = raw.slice(first?.[0].length ?? 0)
149
+
150
+ let time = when
151
+ const second = /^(\S+)/.exec(rest)?.[1]
152
+ if (/^\d{4}-\d{2}-\d{2}$/.test(when) && second && /^\d{1,2}:\d{2}(:\d{2})?$/.test(second)) {
153
+ time = `${when}T${second}`
154
+ rest = rest.slice(second.length)
155
+ }
156
+
157
+ const message = rest.trim()
158
+ if (!time || !message) {
159
+ await alert(
160
+ sessionID,
161
+ "Usage: /delayed-message <time> <message>\n" +
162
+ "Time: HH:MM, YYYY-MM-DDTHH:MM, or +30m / +2h / +1d.",
163
+ )
164
+ return
165
+ }
166
+
167
+ const now = Date.now()
168
+ const at = resolveTime(time, now)
169
+ if (typeof at === "string") {
170
+ await alert(sessionID, `Delayed message not scheduled: ${at}.`)
171
+ return
172
+ }
173
+
174
+ const job: Job = {
175
+ id: crypto.randomUUID(),
176
+ sessionID,
177
+ message,
178
+ fireAt: at,
179
+ directory: ctx.location.directory,
180
+ }
181
+ await ctx.storage.set(key(job.id), job)
182
+ arm(job)
183
+ await rpc.events.emit("scheduled", {
184
+ sessionID,
185
+ id: job.id,
186
+ fireAt: at,
187
+ preview: previewOf(message),
188
+ }).catch(() => {})
189
+ await note(sessionID, `Delayed message scheduled for ${stamp(at)} (in ${humanize(at - now)}).`)
190
+ } catch (error) {
191
+ console.error("[delayed-message] schedule failed:", error)
192
+ await alert(sessionID, `Delayed message failed: ${error instanceof Error ? error.message : String(error)}`)
193
+ }
194
+ },
195
+ })
196
+ })
197
+
198
+ let after: string | undefined
199
+ do {
200
+ const page = await ctx.storage.scan(after ? { prefix: "pending/", after } : { prefix: "pending/" })
201
+ for (const entry of page.entries) {
202
+ const job = entry.value as Job
203
+ if (!job?.id || !job.sessionID || !job.message || !job.fireAt) continue
204
+ if (job.directory !== ctx.location.directory) continue
205
+ arm(job)
206
+ }
207
+ after = page.next
208
+ } while (after)
209
+
210
+ return () => {
211
+ for (const timer of timers.values()) clearTimeout(timer)
212
+ timers.clear()
213
+ }
214
+ },
215
+ })
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@tuzelko/opencode-delayed-message",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Schedule a message that wakes an OpenCode session later, without showing it to the model first.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "delayed",
9
+ "reminder",
10
+ "scheduler",
11
+ "timer",
12
+ "desktop"
13
+ ],
14
+ "license": "MIT",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "exports": {
20
+ ".": "./index.ts",
21
+ "./gui-experimental": "./gui-experimental.cjs"
22
+ },
23
+ "files": [
24
+ "index.ts",
25
+ "rpc.ts",
26
+ "gui-experimental.cjs",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "build": "vite build",
32
+ "prepare": "npm run build",
33
+ "prepack": "npm run build"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/TuzelKO/opencode-delayed-message.git"
38
+ },
39
+ "homepage": "https://github.com/TuzelKO/opencode-delayed-message#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/TuzelKO/opencode-delayed-message/issues"
42
+ },
43
+ "dependencies": {
44
+ "@opencode/plugin": "^2.2.5"
45
+ },
46
+ "devDependencies": {
47
+ "solid-js": "^1.9.18",
48
+ "vite": "^8.3.5",
49
+ "vite-plugin-solid": "^2.11.15"
50
+ }
51
+ }
package/rpc.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ // Bridge between the server plugin and its GUI part: the pill asks `pending`
4
+ // on mount, follows `scheduled`/`fired`/`cancelled`, and cancels by id.
5
+
6
+ const job = {
7
+ type: "object",
8
+ properties: {
9
+ id: { type: "string" },
10
+ fireAt: { type: "number" },
11
+ preview: { type: "string" },
12
+ },
13
+ required: ["id", "fireAt", "preview"],
14
+ additionalProperties: false,
15
+ } as const
16
+
17
+ export const Delayed = Rpc.define({
18
+ id: "delayed-message",
19
+ methods: {
20
+ pending: {
21
+ input: {
22
+ type: "object",
23
+ properties: { sessionID: { type: "string" } },
24
+ required: ["sessionID"],
25
+ additionalProperties: false,
26
+ },
27
+ output: {
28
+ type: "object",
29
+ properties: {
30
+ jobs: { type: "array", items: job },
31
+ },
32
+ required: ["jobs"],
33
+ additionalProperties: false,
34
+ },
35
+ },
36
+ cancel: {
37
+ input: {
38
+ type: "object",
39
+ properties: {
40
+ sessionID: { type: "string" },
41
+ id: { type: "string" },
42
+ },
43
+ required: ["sessionID", "id"],
44
+ additionalProperties: false,
45
+ },
46
+ output: {
47
+ type: "object",
48
+ properties: { ok: { type: "boolean" } },
49
+ required: ["ok"],
50
+ additionalProperties: false,
51
+ },
52
+ },
53
+ },
54
+ events: {
55
+ scheduled: {
56
+ schema: {
57
+ type: "object",
58
+ properties: {
59
+ sessionID: { type: "string" },
60
+ id: { type: "string" },
61
+ fireAt: { type: "number" },
62
+ preview: { type: "string" },
63
+ },
64
+ required: ["sessionID", "id", "fireAt", "preview"],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ fired: {
69
+ schema: {
70
+ type: "object",
71
+ properties: {
72
+ sessionID: { type: "string" },
73
+ id: { type: "string" },
74
+ },
75
+ required: ["sessionID", "id"],
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ cancelled: {
80
+ schema: {
81
+ type: "object",
82
+ properties: {
83
+ sessionID: { type: "string" },
84
+ id: { type: "string" },
85
+ },
86
+ required: ["sessionID", "id"],
87
+ additionalProperties: false,
88
+ },
89
+ },
90
+ },
91
+ })