@tuzelko/opencode-delayed-message 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +126 -2
- package/gui-experimental.cjs +11 -0
- package/index.ts +215 -0
- package/package.json +49 -4
- package/rpc.ts +91 -0
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
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ⏰ opencode-delayed-message
|
|
4
|
+
|
|
5
|
+
**Schedule a message that wakes the session later — without putting it in the prompt now.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@tuzelko/opencode-delayed-message)
|
|
8
|
+
[](https://www.npmjs.com/package/@tuzelko/opencode-delayed-message)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
[](https://opencode.ai)
|
|
11
|
+
[](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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
})
|