dsh-scheduled-message 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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-scheduled-message
3
+ name: dsh-scheduled-message
package/docs/DETAIL.md ADDED
@@ -0,0 +1,440 @@
1
+ # dsh-scheduled-message — design notes
2
+
3
+ Everything here was checked against the installed harness
4
+ (`DeepSeek Harness 0.2.2-rc.2`, `…/resources/app.asar/dsh/node_modules/@deepseek-ai/…`), not against
5
+ a remembered API. File and line references point at the shipped package sources inside that ASAR.
6
+
7
+ - [1. Scope](#1-scope)
8
+ - [2. Platform facts](#2-platform-facts)
9
+ - [3. Host design](#3-host-design)
10
+ - [4. Client design](#4-client-design)
11
+ - [5. Failure and edge behaviour](#5-failure-and-edge-behaviour)
12
+ - [6. Tests](#6-tests)
13
+ - [7. Verification](#7-verification)
14
+ - [8. Deviations from PLAN.md](#8-deviations-from-planmd)
15
+
16
+ ---
17
+
18
+ <a id="1-scope"></a>
19
+ ## 1. Scope
20
+
21
+ The feature is one thing: *this exact composer content, delivered later by the Host*.
22
+
23
+ In scope: a schedule button and dialog, a 12-hour picker, quick picks, an optional Repeat that
24
+ becomes an Automation task, a strip that lists and manages scheduled records, a keyboard shortcut,
25
+ English and Chinese copy, Host-side durable storage and delivery, and late/at-startup delivery.
26
+
27
+ Out of scope, deliberately: attachments on repeating messages (the Automation service delivers text
28
+ only), automatic retries, polling, and any change to the normal send path.
29
+
30
+ <a id="2-platform-facts"></a>
31
+ ## 2. Platform facts
32
+
33
+ ### 2.1 A client plugin is a package export, not a URL
34
+
35
+ The Client half is not registered by naming a file. A package declares `dsh.client` with
36
+ `platform: 'web'` and exports `./client`; the Host turns that declaration into a served bundle under
37
+ `/plugins`.
38
+
39
+ - `dsh-client-modules/src/index.ts:203` — `clientExportOf` resolves `exports["./client"]`, accepting a
40
+ string or one level of `default`.
41
+ - `dsh-client-modules/src/client/manifest.ts` — `WebBootEntry.id` is *the package name*; `inject`
42
+ names package rows whose factories must arrive first; `external` adds non-baseline module requests.
43
+
44
+ The bundle itself is a lazy-CJS factory. Every shipped plugin ends with the same shape
45
+ (`dsh-ops/lib/client.js`, tail):
46
+
47
+ ```js
48
+ window.__ModuleLoader__.load({ id: "<package>", factory: (require) => { … return load("client/index.js") } })
49
+ ```
50
+
51
+ Executing the bundle only registers the factory; all side effects, including CSS injection, run when
52
+ the factory materializes. `require` resolves against the frozen platform table plus the factories
53
+ already registered, so a plugin bundle may only ask for baseline modules. The two this bundle uses —
54
+ `react` and `react-dom` — are both in that table (`react-dom` is used by `dsh-config-manager`, and
55
+ `react-dom/client` by `dsh-claude-style`).
56
+
57
+ ### 2.2 Importable Host services
58
+
59
+ - `ctx.sessionController.prompt(request, signal)`
60
+ `dsh-api-session-controller/src/index.ts:467`, request type `src/types.ts:324`:
61
+ `{ requestId, sessionId, mode: 'queue' | 'steer', content: PromptContentPart[], clientTimeZone? }`.
62
+ `requestId` is idempotent — the same id is never delivered twice, which is the crash-safety
63
+ mechanism for retries. `resolveAgent(sessionId)` answers `{ agent }` or `{ error }`.
64
+ - `ctx.attachments` — `dsh-attachment/src/index.ts:116` `saveImages`, `:195` `readImage`,
65
+ `:247` `readFileStream`; value shapes in `src/types.ts:127`/`:136`.
66
+ - `ctx.fileUploads` — `dsh-client-file-upload/src/index.ts:127` `uploadStream({ sessionId, data })`
67
+ returns `{ receiptId, file }`; `:194` `resolve(agent, receiptId)` returns the staged attachment for
68
+ that exact Agent.
69
+ - `ctx.schedule` — `dsh-schedule/src/index.ts:381` `create(request, actorSessionId?, signal?)`;
70
+ timing union in `src/types.ts:335`, request in `src/types.ts:478`. Title ≤120 chars, prompt
71
+ non-empty, both trimmed.
72
+ - `ctx.storageDomain` — `dsh-storage-domain/src/spec.ts:91` `domainTable`, `:107` `defineDomain`;
73
+ `src/index.ts:126` calls `tableSpec.valueSchema.parse(raw)` on read. The service is provided as
74
+ `storageDomain` (`dsh-storage-domain/lib/index.js`: `domainCtx.provide("storageDomain", facility)`),
75
+ and unit names must match `UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/` exported by `@deepseek-ai/dsh-storage`.
76
+ - `ctx.connection.fetch` — `dsh-client-connection/src/rpc.ts:153` `ConnectionFetchRoute`,
77
+ `:175` `register(route)`. Paths are absolute below `/api` (`dsh-client-file-upload/src/protocol.ts:2`
78
+ uses `/api/session/uploadFileBinary`). Only owner peers reach an exact route.
79
+ - `ctx.timer.timeout(callback, delay)` — `cordis-plugin-timer/src/index.ts:29`.
80
+
81
+ ### 2.3 Composer slots and their props
82
+
83
+ - `conversation.input.right` — list, session scope, **no owner values**
84
+ (`dsh-client-ui-conversation/src/client/contract/slots.ts:327`). The composer renders it with an
85
+ empty owner object: `renderSlot('conversation.input.right', {})`
86
+ (`src/client/skeleton/InputBar.tsx:541`).
87
+ - `conversation.input.dock` — list, session scope, owner `InputZone` = `{ session, input }`
88
+ (`contract/slots.ts:307`; the interface is at `:451`). This owner share exists on the dock only.
89
+ - A session-scoped entry receives the standard kit (`contract/slots.ts:397`):
90
+ `useConversation`, `useInput`, **`inputActions`**, plus whatever `inject` returns, plus `t` when
91
+ `locale` is given. There is no `input` prop on `conversation.input.right`: the live draft, phase
92
+ and attachment ids are read with the `useInput` selector hook
93
+ (`const input = props.useInput(state => state)`), which is how the composer's own `InputBar`
94
+ reads them (`InputBar.tsx:57`).
95
+ - Registration form, as used by the shipped docks
96
+ (`src/client/queue/QueueDock.tsx:393`, `src/client/skeleton/TodoPanel.tsx:116`):
97
+
98
+ ```js
99
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
100
+ name: 'conversation.input.dock', id: 'queue', order: 20, locale: NS,
101
+ inject: sessionId => ({ … }),
102
+ }, Component))
103
+ ```
104
+
105
+ `inject` receives the session id, so this bundle takes the conversation id from there and keeps a
106
+ `session.sessionId` fallback.
107
+ - Attachment hand-off: `dsh-client-ui-conversation/src/client/service.ts:479`
108
+ `serializeDraftAttachments(ids)`. It throws for a vanished id and for an upload that has not
109
+ finished, which the dialog reports instead of scheduling a partial attachment.
110
+
111
+ ### 2.4 Shortcuts, locale, layout
112
+
113
+ - `ctx.shortcuts.register(command)` — `dsh-client-shortcuts/src/client/types.ts:137`; binding type in
114
+ `src/binding.ts` (`{ code, secondCode?, modifiers }`), profiles
115
+ `desktop|web × macos|windows|linux`. Command ids must match
116
+ `^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$`.
117
+ - `ctx.locale.register(ns, dicts)` / `ctx.locale.bind(ns)` —
118
+ `dsh-client-locale/src/client/index.ts:403`, `:459`. A dictionary is `Record<string, string>` with
119
+ `{name}` placeholders.
120
+ - `ctx.layout.selectPanel('schedules')` — the Automation tasks page id, exactly as
121
+ `dsh-client-ui-schedule/src/client/index.ts:76` defines it (`const PANEL_ID = 'schedules'`). The call
122
+ throws when that panel is not registered, so it is wrapped.
123
+
124
+ <a id="3-host-design"></a>
125
+ ## 3. Host design
126
+
127
+ ### 3.1 Shape
128
+
129
+ `index.js` exports `{ name, inject: ['storageDomain'], apply(ctx) }`. `apply` opens one
130
+ `ctx.inject(['storageDomain', 'sessionController', 'connection', 'attachments', 'fileUploads'])`
131
+ scope for the whole feature and a nested `ctx.inject(['schedule'])` for Repeat, so a profile without
132
+ the Automation service still loads and answers `repeat-unavailable` instead of failing.
133
+
134
+ `lib/core.js` holds all logic that is not I/O: 12-hour conversion, wall-clock→instant resolution per
135
+ IANA zone, request validation, RRULE text, timer chunking, retention, title summary. It has no
136
+ imports, so the same file is unit-tested and inlined into the browser bundle.
137
+
138
+ ### 3.2 Durable record
139
+
140
+ Domain `dsh_scheduled_message` (storage unit names are matched against
141
+ `^[a-z][a-z0-9_]*$` by `@deepseek-ai/dsh-storage`, so the hyphenated package name is not a legal
142
+ domain name — the smoke test asserts the pattern), version 1, table `messages`, keyed by record id:
143
+
144
+ ```ts
145
+ {
146
+ id, sessionId, dueAt, timeZone, // dueAt is one absolute instant
147
+ text, images: ImageAttachmentRef[], files: FileAttachmentRef[],
148
+ createdAt, updatedAt,
149
+ status: 'pending' | 'sending' | 'sent' | 'failed' | 'cancelled',
150
+ sentAt?, late?: boolean, error?: string
151
+ }
152
+ ```
153
+
154
+ Rules: at most 500 `pending` records; `sent`/`cancelled` records are pruned after 7 days (at open and
155
+ after each delivery pass); a record left in `sending` by a crash is retried at the next start, and its
156
+ `requestId` (`dsm-<id>`) makes the retry idempotent.
157
+
158
+ The table's `valueSchema` is a plain object with a `parse` method rather than a zod schema: the
159
+ storage layer only ever calls `parse` (`dsh-storage-domain/src/index.ts:126`), so this keeps the
160
+ bundle installable with no runtime dependency while still validating every record that crosses the
161
+ durable boundary.
162
+
163
+ ### 3.3 Routes
164
+
165
+ | Path (below `/api`) | Method | Body | Purpose |
166
+ |---|---|---|---|
167
+ | `/dsh-scheduled-message/create` | POST | streaming, 64 MiB cap | validate, convert attachments, store, re-arm |
168
+ | `/dsh-scheduled-message/list` | GET | `?sessionId=` | pending + failed + sending + sent within 24 h |
169
+ | `/dsh-scheduled-message/cancel` | POST | `{ id }` | pending or failed → cancelled |
170
+ | `/dsh-scheduled-message/reschedule` | POST | `{ id, dueAt }` | pending only; the new instant must be ≥30 s ahead |
171
+ | `/dsh-scheduled-message/send-now` | POST | `{ id }` | deliver now, under the delivery lock |
172
+ | `/dsh-scheduled-message/retry` | POST | `{ id }` | failed → pending at `now`, then deliver |
173
+
174
+ Every route answers `200` with `{ ok: true, value }` or `{ ok: false, error: { code, message } }`;
175
+ domain refusals are not HTTP failures, and the carrier already answers `401`/`403` before a handler
176
+ runs. Codes: `invalid-request`, `too-soon`, `empty`, `invalid-due-at`, `invalid-time-zone`,
177
+ `invalid-repeat`, `repeat-with-attachments`, `too-many-images`, `invalid-images`, `invalid-files`,
178
+ `text-too-long`, `invalid-session`, `unknown-session`, `unknown-receipt`, `repeat-unavailable`,
179
+ `too-many-pending`, `not-pending`, `not-found`, `internal`.
180
+
181
+ ### 3.4 Create flow
182
+
183
+ 1. **Validate** (`validateCreateRequest`, pure, clock injected): instant at least 30 s ahead, an
184
+ absolute ISO instant, a real IANA zone, text ≤200 000 chars, ≤10 images, not empty, and no
185
+ attachments when Repeat is on.
186
+ 2. **Resolve the conversation** with `sessionController.resolveAgent`. A missing conversation is
187
+ refused before anything is stored.
188
+ 3. **Repeat branch**: `ctx.schedule.create({ title: summariseTitle(text), prompt: text,
189
+ timing: { kind: 'rrule', rrule }, target: { kind: 'session', sessionId } })` and answer
190
+ `{ kind: 'automation', taskId, rrule }`. No scheduled-message record is written.
191
+ 4. **One-shot branch**: `saveImages` for the browser-encoded images, `fileUploads.resolve` for each
192
+ staged file receipt, then one `pending` record and a re-armed timer.
193
+
194
+ ### 3.5 Timer
195
+
196
+ One `ctx.timer.timeout`, armed for the earliest `pending` (or crash-left `sending`) record:
197
+
198
+ - delay = `min(dueAt − now, 2^31 − 1)`, so a wait longer than `setTimeout` can hold fires early and
199
+ re-arms;
200
+ - re-armed after open, after every create/cancel/reschedule/retry, and after every delivery pass;
201
+ - an arm sequence number makes a slower, older re-arm a no-op;
202
+ - no polling, no interval.
203
+
204
+ ### 3.6 Delivery
205
+
206
+ Delivery is serialized through one promise chain, so two due records never interleave:
207
+
208
+ 1. mark `sending` (durably) before any I/O;
209
+ 2. rebuild the content: `{ type: 'text' }`, then one re-read image per reference
210
+ (`readImage` → base64), then one re-staged file per reference
211
+ (`uploadStream({ sessionId, data: readFileStream(ref) })` → `{ type: 'file', receiptId }`);
212
+ 3. `sessionController.prompt({ requestId: 'dsm-<id>', mode: 'queue', content, clientTimeZone })`
213
+ under a 120 s `AbortSignal.timeout`;
214
+ 4. success → `sent` with `sentAt` and `late` (true when it settled more than 60 s after `dueAt`);
215
+ failure → `failed` with a ≤500-character reason. Never auto-retried;
216
+ 5. prune, then re-arm.
217
+
218
+ `mode: 'queue'` is what Enter does while an agent is running, so a busy conversation is not an error.
219
+
220
+ ### 3.7 Lifecycle
221
+
222
+ One `ctx.effect` owns the timer and the domain handle: disabling the plugin stops the timer, removes
223
+ the routes, closes the domain and leaves every record on disk. Re-enabling re-arms from storage.
224
+
225
+ <a id="4-client-design"></a>
226
+ ## 4. Client design
227
+
228
+ ### 4.1 Bundle
229
+
230
+ `client.js` is generated by `scripts/build-client.mjs` from three sources: `client.src.js` (the
231
+ factory body), `lib/core.js` (inlined as a closure-scoped `core`) and `locale/*.json` (inlined as
232
+ `EN`/`ZH`). The artifact is committed, and both a unit test and `npm run check` fail when it is
233
+ stale, so the shared date logic really is one implementation instead of two.
234
+
235
+ The factory returns `{ name, inject: ['slots', 'locale'], apply }`. Everything registered in `apply`
236
+ is owned by `ctx.effect`, so a disabled plugin removes its styles, slots and shortcut.
237
+
238
+ ### 4.2 Button and dialog
239
+
240
+ `ScheduleButton` registers into `conversation.input.right` (order 900). It reads the draft, the
241
+ attachment ids and the phase through the standard `useInput` selector hook, is disabled when there
242
+ is nothing to send or the composer is busy, and keeps the editor focus by cancelling `mousedown`.
243
+ A seat mounted without the hook (no composer context) falls back to an inert snapshot instead of
244
+ throwing.
245
+
246
+ The dialog is a `ReactDOM.createPortal` into `document.body` — the composer card clips fixed
247
+ overlays, so a portal is required, not cosmetic. It holds:
248
+
249
+ - header, and a message preview (first two lines plus an attachment count);
250
+ - quick picks: *In 1 hour*, *This evening 8:00 PM* (hidden once past), *Tomorrow 9:00 AM*,
251
+ *Next Monday 9:00 AM*;
252
+ - `<input type="date">` with `min` = today in the browser zone, hour 1–12, minute in 5-minute steps,
253
+ and an **AM / PM** segmented control;
254
+ - a summary line, red when the selection is in the past or under 30 s away, and red for a wall clock
255
+ a DST gap removed (that case is refused rather than shifted);
256
+ - a **Repeat** checkbox with the frequency and a rule-specific note; the day-29–31 case notes that
257
+ shorter months are skipped;
258
+ - footer with Cancel and the primary action, which becomes **Create automation** when Repeat is on.
259
+
260
+ Escape, the backdrop and the header button close it; Tab is trapped; focus returns to whatever was
261
+ focused before it opened. Submitting converts the selection with `core.wallClockToInstant` in the
262
+ browser's zone and sends one absolute instant, so the Host never has to guess a zone.
263
+
264
+ Success clears the draft and its attachments with `inputActions.setDraft('')` /
265
+ `removeAttachment(id)`, shows a notice, refreshes the strip, and for Repeat opens the Automation task
266
+ page. Failure keeps the draft, keeps the dialog open and shows the reason inline.
267
+
268
+ ### 4.3 Shortcut
269
+
270
+ `dsh-scheduled-message.open`, label "Schedule message", aliases `schedule`/`later`/`deliver`,
271
+ `{ code: 'Enter', modifiers: ['alt'] }` as the default on all six profiles, `regions: ['editable']`,
272
+ `modals: []`. DSH's own Enter handler swallows Alt+Enter without a line break and without submitting
273
+ (`ui-conversation/src/client/input/editor/keymap.ts`), so the chord was free.
274
+
275
+ `resolve()` returns `pass` outside an editor, `blocked` with a localized reason when no composer is
276
+ mounted or when the draft is empty or mid-send, and otherwise `handled` with the mounted composer's
277
+ opener. Mounted composers register themselves while mounted; the last one that received text wins, so
278
+ a split view picks the conversation the user was last typing in.
279
+
280
+ ### 4.4 Strip
281
+
282
+ `conversation.input.dock` (order 30). Collapsed it shows the count and the next due time; expanded it
283
+ lists rows with the preview, attachment icons, the time, and **Send now** / **Change time** /
284
+ **Cancel** (cancel asks once inline). Failed rows show the stored reason and **Retry**; rows sent in
285
+ the last 24 h are dimmed, with a tick and "sent late" when applicable. It refreshes on mount, after
286
+ each action, on window `focus`, and once about 2 s after the next due instant. No polling.
287
+
288
+ ### 4.5 Styling and copy
289
+
290
+ All classes are prefixed `dsm-`; surfaces and text use `--dsw-alias-bg-*`, `--dsw-alias-border-*`,
291
+ `--dsw-alias-label-*` and `--dsw-alias-state-*`, so light and dark both work. `#F97316` (hover
292
+ `#EA6A0C`) is the only literal colour. Under 640 px the dialog becomes a bottom sheet with
293
+ `padding-bottom: max(16px, env(safe-area-inset-bottom))`, 44 px targets and 16 px inputs;
294
+ `prefers-reduced-motion: reduce` removes the sheet and button transitions. Every visible string goes
295
+ through the `dsh-scheduled-message` namespace, and times are formatted with
296
+ `Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone })`.
297
+
298
+ <a id="5-failure-and-edge-behaviour"></a>
299
+ ## 5. Failure and edge behaviour
300
+
301
+ | Case | Behaviour |
302
+ |---|---|
303
+ | Composer busy (`phase !== 'plain'`) | Button disabled; shortcut reports "Wait for the message to finish sending". |
304
+ | Empty draft | Button disabled; shortcut reports "Type a message first". |
305
+ | File still uploading | `serializeDraftAttachments` throws → "Wait for uploads to finish"; draft kept. |
306
+ | Attachment vanished | Same path, distinct message; nothing is scheduled. |
307
+ | Agent running at due time | Delivered with `mode: 'queue'`, exactly like Enter. |
308
+ | Conversation archived or deleted | `resolveAgent`/`prompt` fails → `failed` with the reason; Cancel still works. |
309
+ | Model rejects images | `prompt` fails → `failed` with the host diagnostic. |
310
+ | Two tabs act on one record | The Host serializes; the stale call gets `not-pending` and the UI refreshes. |
311
+ | Browser and Host in different zones | Only one absolute instant is stored; the zone is kept for display and for `DTSTART`. |
312
+ | DST gap in the picker | The selection is refused with a red summary instead of being silently shifted. |
313
+ | DST/Host zone change | One-shots are unaffected; RRULE keeps wall-clock time through `DTSTART;TZID`. |
314
+ | Monthly repeat on the 29th–31st | `BYMONTHDAY`, with a note that shorter months are skipped. |
315
+ | Repeat with attachments | Blocked in the UI and in the Host validator. |
316
+ | DSH closed over a due time | Delivered at the next start, `late: true`. |
317
+ | Crash mid-delivery | The record stays `sending`; the next start retries it; `requestId` prevents a duplicate. |
318
+ | Plugin disabled | Timer and routes stop; records stay; enabling resumes. |
319
+
320
+ <a id="6-tests"></a>
321
+ ## 6. Tests
322
+
323
+ `npm test` (33 tests):
324
+
325
+ - `test/core.test.mjs` — 26 tests. 12↔24-hour conversion including 12 AM/PM, a fixed-zone instant
326
+ (09:00 in `Asia/Hong_Kong` → `01:00Z`), a round-trip across five zones and three dates, a DST gap
327
+ refused, an ambiguous hour resolved, every validator rejection (past, <30 s, empty, >10 images,
328
+ repeat with attachments, oversized text, bad zone, bad instant), RRULE text for all four
329
+ frequencies including `DTSTART;TZID=`, `WKST`, `BYMONTHDAY`, nearest-due selection, timer chunking
330
+ past 2^31−1, retention, the storage-unit-name pattern and the 120-character title summary.
331
+ - `test/client.test.mjs` — 6 tests. The committed bundle equals a fresh build; both dictionaries
332
+ declare one key set; executing `client.js` through a `window.__ModuleLoader__` facade yields the
333
+ documented plugin; `apply` registers exactly the button (`conversation.input.right`, order 900) and
334
+ the strip (`conversation.input.dock`, order 30) and one shortcut with Alt+Enter on all six
335
+ profiles, `pass` outside an editor and a `blocked` reason inside one with no composer; the
336
+ registered button mounts with a fake input machine and is clickable exactly when `useInput`
337
+ reports text or an attachment and the phase is `plain`; the bundle requires nothing outside the
338
+ baseline table.
339
+ - `test/host.test.mjs` → `scripts/host-smoke.mjs` — 66 assertions against in-memory services:
340
+ the six routes, six refusals that write nothing, a text create/list/deliver round trip with the
341
+ derived request id and queued mode, a failed delivery plus retry with the same id, send-now,
342
+ cancel and `not-pending`/`not-found`, reschedule and the 30 s guard, crash recovery from
343
+ `sending`, an image and a file surviving to delivery, Repeat producing an Automation task with the
344
+ expected title/target/RRULE and no local record, and a profile without the Automation service
345
+ refusing Repeat while one-shots still work.
346
+
347
+ `npm run check` re-checks the bundle freshness, parses all four JS files, verifies the manifest
348
+ exports, the dictionary parity, that every `t('…')` key exists in both dictionaries, that promised
349
+ files exist, and that `lib/core.js` stays dependency-free.
350
+
351
+ <a id="7-verification"></a>
352
+ ## 7. Verification
353
+
354
+ `npm test` reports **32 tests passing** (26 core, 5 client, 1 Host wrapper that runs 66 assertions),
355
+ and `npm run check` passes.
356
+
357
+ ### 7.1 Automated
358
+
359
+ - `node --check` on all four JavaScript files; both JSON dictionaries and the patch YAML parse.
360
+ - The bundle-freshness gate, the dictionary-parity gate and the `t('…')` key-coverage gate.
361
+ - The Host end-to-end smoke test (66 assertions) described in [§6](#6-tests).
362
+
363
+ ### 7.2 Live, on an isolated DSH 0.2.2-rc.2 instance
364
+
365
+ A throwaway `web` profile was materialised under a temporary `DSH_HOME` (never the running profile,
366
+ never the user's data) and booted with the CLI:
367
+
368
+ ```sh
369
+ DSH_HOME=<tmp> dsh --profile web --dump-config # composition
370
+ DSH_HOME=<tmp> dsh --profile web --no-open --port 0 # real boot
371
+ ```
372
+
373
+ Observed, in order:
374
+
375
+ 1. `--dump-config` printed the `dsh-scheduled-message` layer and its inserted row, so the package was
376
+ resolution-visible and `dsh.bundle.patch` composed without error.
377
+ 2. The booted instance's `window.__DSH_BOOT__` contained
378
+ `plugins/??dsh-scheduled-message/client.js&rev=3921005a625d`, which only happens when the Host
379
+ entry activated and the module scanner resolved `exports["./client"]`.
380
+ 3. `GET /plugins/??dsh-scheduled-message/client.js&rev=…` answered `200` with this bundle (71 956
381
+ bytes).
382
+ 4. `GET /api/dsh-scheduled-message/list?sessionId=…` answered `{"ok":true,"value":{"items":[]…}}`.
383
+ 5. `POST /api/dsh-scheduled-message/create` with a real session answered
384
+ `{"ok":true,"value":{"kind":"once",…,"status":"pending"}}`, and the real JSON storage backend wrote
385
+ `storages/dsh_scheduled_message.json` with a record in the expected shape.
386
+ 6. The real timer fired at the due instant: due `05:42:41.945Z`, sent `05:42:41.955Z`, `late:false`.
387
+ The delivered text became that session's title, so the prompt really entered the conversation
388
+ rather than only updating the record.
389
+ 7. `POST create` with `repeat: "weekdays"` answered
390
+ `{"kind":"automation","taskId":"schedule-…","rrule":"DTSTART;TZID=Asia/Hong_Kong:…T134500\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;WKST=MO"}`,
391
+ so the real `RRule.parse` accepted the generated rule and a task was stored.
392
+ 8. Startup delivery: a message due `05:44:05.839Z` while the Host was stopped was delivered at
393
+ `05:44:35.698Z` on the next start; a second cycle with the Host down for over a minute (due
394
+ `05:46:17.931Z`, sent `05:48:22.534Z`) recorded `late: true`.
395
+
396
+ That run found two real defects, both fixed and now covered by tests:
397
+
398
+ - the Host reached `ctx.timer` without declaring it, which cordis refuses
399
+ (`cannot get property "timer" without inject`); `timer` is now in the inject list, and the Client
400
+ half likewise takes `sessions` and `layout` from their own optional scopes instead of reading them
401
+ off `ctx`;
402
+ - the storage domain was named `dsh-scheduled-message`, which `@deepseek-ai/dsh-storage`'s
403
+ `UNIT_NAME_RE` (`/^[a-z][a-z0-9_]*$/`) rejects; the domain is now `dsh_scheduled_message` and
404
+ `test/core.test.mjs` asserts the pattern.
405
+
406
+ `rearm()` was also made non-throwing: a re-arm failure must not fail a create whose record is already
407
+ durable.
408
+
409
+ A third defect came from the running app after 0.1.0 shipped: the composer button rendered but never
410
+ became clickable, even with a message typed. The cause was the contract misreading in §8 — the seat
411
+ read `props.input`, which `conversation.input.right` never passes, so `draft` stayed `''` and
412
+ `disabled` stayed `true`. 0.1.1 reads the state with `props.useInput(state => state)` and mounts the
413
+ registered component in `test/client.test.mjs` to assert the enabled/disabled and tooltip outcome for
414
+ typed, empty, attachment-only and sending states. The live dialog, strip and shortcut are still not
415
+ observed (§7.3).
416
+
417
+ ### 7.3 Not verified
418
+
419
+ 1. **The dialog has never been rendered in a browser.** No screenshot, no focus-trap check, no
420
+ light/dark visual check, no 375 px sheet check. The layout is CSS-only reasoning.
421
+ 2. **Alt+Enter has not been pressed in a real editor**, and the strip has not been seen expanded.
422
+ Their registrations are asserted structurally, and the shortcut's `resolve()` paths are unit
423
+ tested, but no real keystroke was delivered.
424
+ 3. **The notice path** (`conversation.input.for(scope).notify(...)`) and
425
+ `layout.selectPanel('schedules')` were never observed: both run only from browser interactions.
426
+ 4. **A message with images or files has not been delivered by a real Host.** The re-read, re-upload
427
+ and re-stage paths are covered by the smoke test's stand-ins only.
428
+ 5. **A physical phone** was never used.
429
+
430
+ <a id="8-deviations-from-planmd"></a>
431
+ ## 8. Deviations from PLAN.md
432
+
433
+ | Plan | Here | Why |
434
+ |---|---|---|
435
+ | `client.js` hand-written in one file | `client.src.js` + `scripts/build-client.mjs` → `client.js` | The bundle is lazy-CJS and cannot import `lib/core.js` or read JSON. Generating it keeps one implementation of the date/clock/RRULE logic, with a test that fails on drift. |
436
+ | `dependencies: { zod: 4.6.6 }` | no dependency; the table's `valueSchema` is a plain `{ parse }` | The storage layer only calls `parse` (`dsh-storage-domain/src/index.ts:126`), and the package then installs with nothing to resolve. |
437
+ | Button reads `useInput(s => s.draft)` | reads `props.useInput(state => state)` | 0.1.0 read `props.input`, which `conversation.input.right` never passes — its `renderSlot` owner is `{}` (`InputBar.tsx:541`) and only `conversation.input.dock` carries `InputZone`. That left the button permanently disabled; 0.1.1 restores the documented hook. |
438
+ | Six routes with mixed HTTP status codes | all six answer `200` and carry `{ ok: false, error }` | Domain refusals are not transport failures, and the carrier owns `401`/`403`. |
439
+ | `test/core.test.mjs` only | `core`, `client` and `host` suites plus `scripts/host-smoke.mjs` | The Host half had no execution coverage otherwise, and the generated bundle needed a freshness gate. |
440
+ | `list` returns "pending + failed + sent in the last 24 h" | same, plus `sending` | A record mid-delivery should stay visible in the strip instead of vanishing for a moment. |
Binary file
package/icon.svg ADDED
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Schedule message">
2
+ <defs>
3
+ <linearGradient id="dsm-fill" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#FB923C"/>
5
+ <stop offset="1" stop-color="#EA6A0C"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#dsm-fill)"/>
9
+ <circle cx="27" cy="33" r="14" fill="none" stroke="#FFFFFF" stroke-width="3.4"/>
10
+ <path d="M27 24.5V33l6.4 4" fill="none" stroke="#FFFFFF" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/>
11
+ <path d="M43 36.5 56 42l-13 5.5 1.4-5.5Z" fill="#FFFFFF"/>
12
+ <path d="M44.4 42h-6.2" fill="none" stroke="#FFFFFF" stroke-width="3.4" stroke-linecap="round"/>
13
+ </svg>