dsh-power-after-task 0.0.0-stage → 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to `dsh-power-after-task` are documented here.
4
+
5
+ ## 1.0.1
6
+
7
+ - Search metadata: npm `description` and `keywords` rewritten to cover DeepSeek
8
+ Harness / DSH / cordis / Windows power vocabulary, so the package is findable
9
+ on npm and the ecosystem index. No runtime change.
10
+ - Bundled README rewritten for the published package (badges, tighter sections).
11
+ - Repository only (not in the tarball): CI check workflow, issue/PR templates,
12
+ security policy, contributing guide, dependabot, editorconfig; the inert
13
+ `publish.yml` workflow removed (releases go through npm staged publishing).
14
+
15
+ ## 1.0.0
16
+
17
+ Initial release.
18
+
19
+ - Arm one Windows power action per session — `sleep`, `shutdown`, or `restart` — and run it when that session's task finishes.
20
+ - Completion is the session's Agent going idle (`api-session/status`), so an error stop, a cancellation, or an archived session all count; any new activity puts the arm back to waiting.
21
+ - Cancellable grace countdown between completion and the power command, defaulting to 45 seconds and capped at 1 hour.
22
+ - Composer-dock control: one power icon that expands inline into Action and Grace drop-downs plus Arm; while armed it prints nothing but a smaller lit icon and Cancel.
23
+ - Frame-wide countdown ribbon in `shell.overlay`: semi-transparent band, a large `m:ss.mmm` timer on animation frames, a tiny `Until sleep` / `Until shut down` / `Until restart` caption, and a Cancel button. Click-through everywhere else, so it never blocks the page.
24
+ - The arm stays visible until the countdown runs successfully or you cancel it; a failed power command reports a `failed` state instead of silently returning to normal.
25
+ - Enabling or disabling the plugin row adds or removes the dock entry live — the widget probes this package's route.
26
+ - All colours come from `--dsw-alias-*` theme tokens, so the UI follows the Harness light/dark setting.
27
+ - Plugin row ships `disabled: true`: enable it in Settings → Plugins when you need it.
28
+ - Plugin-owned `/power-task` route. Mutating endpoints refuse a request that proves it is not the page — a non-loopback `Host`, a browser-reported cross-site `Sec-Fetch-Site`, or a non-loopback `Origin` — while accepting the page's own transport, which forwards `Cookie` but no fetch metadata. The route cannot run a power action immediately.
29
+ - `GET /power-task/debug` reports sessions, origin, and the headers of the last read and mutating request, so a rejected Arm is diagnosable without a browser console.
30
+ - `power_after_task` agent tool: `status`, `arm`, `cancel`, `execute`.
31
+ - English and Persian locale dictionaries plus Plugin Manager title, description, and icon.
32
+ - Local release helper: `npm run stage` publishes through npm staged publishing with `NPM_TOKEN` from a gitignored `.env`, via a throwaway owner-only npmrc and a pinned npm 11 under `.npm-tools/`; `stage:list`, `stage:view`, `stage:approve`, and `stage:reject` manage what is waiting.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mori
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,93 @@
1
- # Temporary Holding Version
1
+ # dsh-power-after-task
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
+ [![npm](https://img.shields.io/npm/v/dsh-power-after-task?logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/dsh-power-after-task)
4
+ [![license](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
5
+ [![CI](https://github.com/white-sparrow/dsh-power-after-task/actions/workflows/ci.yml/badge.svg)](https://github.com/white-sparrow/dsh-power-after-task/actions/workflows/ci.yml)
6
+
7
+ Windows **Sleep / Shut down / Restart** after the current agent task finishes — a compact, disabled-by-default DeepSeek Harness plugin.
8
+
9
+ Nothing happens on its own: the power command only runs after you press **Arm**, the agent has actually stopped working, and the grace countdown expires. **Cancel** at any point returns it to off.
10
+
11
+ ## Requirements
12
+
13
+ - **Windows** host — power commands are Windows-specific; other platforms are rejected with an error.
14
+ - **Web composition** (`dsh web`): the dock control talks to the Host over this plugin's own HTTP route; a Desktop page has no web server, so no control appears there.
15
+ - Node 20+ for the Harness Host. No build step, no dependencies.
16
+
17
+ ## Install and enable
18
+
19
+ ```
20
+ Plugin Manager → Install bundle → dsh-power-after-task
21
+ ```
22
+
23
+ The bundle's patch inserts the plugin row with `disabled: true`, so installing changes nothing else. Enable when you need it via **Settings → Plugins → Power after task**; the dock entry appears within about two seconds, and disabling the row (which also clears every pending arm) removes it again — no page refresh needed.
24
+
25
+ ## UI
26
+
27
+ One entry in the composer dock, beside Activity and Usage:
28
+
29
+ - **Idle** — just a small power icon.
30
+ - **Click** — expands inline into two drop-downs (Action, Grace) and an **Arm** button.
31
+ - **Armed** — icon shrinks and lights up (brand while waiting, amber while counting), a **Cancel** button appears; hover shows action + remaining time. No status text by design.
32
+
33
+ While a countdown runs, a wide semi-transparent ribbon (registered in `shell.overlay`) shows a large `m:ss.mmm` timer, a tiny `Until sleep / Until shut down / Until restart` caption, and a **Cancel** button. The layer is click-through — only Cancel takes pointer events — and a failed power command keeps the ribbon visible with the error.
34
+
35
+ All colours are `--dsw-alias-*` theme tokens, so dark/light follows the Harness theme automatically.
36
+
37
+ ## When the task counts as finished
38
+
39
+ The task is the whole span between your prompt and the agent's turn stop — across steps, continuations, and subagents. It counts as finished when the agent answers, errors out without a result, or you cancel; also if the session is archived/removed while armed. It does **not** fire while a goal driver or continuation is still running, and any new message puts the countdown back to waiting. Detection uses the session-status signal (`api-session/status`), not a guess.
40
+
41
+ ```
42
+ Arm → waiting → agent goes idle → grace countdown → power command
43
+ ↑ new activity returns here Cancel → off
44
+ ```
45
+
46
+ **Grace** (default 45 s, choices 15 s / 30 s / 1 m / 2 m / 5 m, cap 3600 s) is the cancellable gap between "finished" and "fires" — long enough to rescue a mis-click or unsaved work.
47
+
48
+ Commands run: `shutdown.exe /s /t 0`, `shutdown.exe /r /t 0`, or Sleep via `kernel32!ApplicationSuspend` (deliberately not `rundll32 SetSuspendState`, which hibernates when hibernation is enabled; `rundll32` is only the fallback).
49
+
50
+ ## Agent tool
51
+
52
+ `power_after_task` — `action: status | arm | cancel | execute`, `session`, `power: sleep | shutdown | restart`, `grace_seconds`. `execute` fires immediately and is not reachable from the HTTP route; the page can only Arm and Cancel.
53
+
54
+ ## Configuration
55
+
56
+ Set in `cordis.patch.yml` (override with your own patch):
57
+
58
+ | Key | Default | Meaning |
59
+ | --- | --- | --- |
60
+ | `defaultAction` | `shutdown` | Preselected action: `sleep` · `shutdown` · `restart` |
61
+ | `graceSeconds` | `45` | Countdown after finish; capped at 3600 |
62
+
63
+ ## Security
64
+
65
+ Arm/Cancel refuse requests that *prove* they are not this page: non-loopback `Host` (DNS rebinding), browser-reported cross-site `Sec-Fetch-Site`, or non-loopback `Origin`. Requests carrying none of that evidence pass — that is the Harness page transport (it forwards `Cookie` but not fetch metadata) or a local process, which could run `shutdown.exe` directly anyway. Route: `POST /power-task/arm`, `POST /power-task/cancel`, `GET /power-task/state`, `GET /power-task/debug`. See [SECURITY.md](SECURITY.md).
66
+
67
+ ## Files
68
+
69
+ | File | Role |
70
+ | --- | --- |
71
+ | `index.js` | Host half: `/power-task` route, session watcher, countdown, Windows power commands, tool |
72
+ | `client.js` | UI half: composer-dock control + `shell.overlay` countdown ribbon |
73
+ | `cordis.patch.yml` | Plugin row, disabled by default, with `config` |
74
+ | `locale/en.json`, `locale/fa.json` | Plugin Manager title/description |
75
+ | `icon.svg` | Plugin Manager artwork |
76
+
77
+ ## Troubleshooting
78
+
79
+ | Symptom | Fix |
80
+ | --- | --- |
81
+ | No dock entry | Row is disabled — enable it in Settings → Plugins |
82
+ | Arm answers `403` | Read `GET /power-task/debug` → `lastPost`: `host`, `secFetchSite`, `origin`, `accepted`. A non-loopback `host` means a proxy or foreign hostname |
83
+ | Ribbon missing after upgrade | Refresh the page once to load the new client bundle |
84
+ | `… failed` in the ribbon | Command rejected — power settings, hibernation/fast-startup, shutdown privileges; Cancel clears state |
85
+ | Nothing fires | Session is still running (goal continuation, subagent, queued message) |
86
+
87
+ ## Development and releases
88
+
89
+ See [CONTRIBUTING.md](CONTRIBUTING.md) — checks (`npm run check`), local debugging, and the staged npm release flow.
90
+
91
+ ## License
92
+
93
+ MIT — see [LICENSE](LICENSE).
package/client.js ADDED
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Client half of `dsh-power-after-task`.
3
+ *
4
+ * Two seats:
5
+ * - `conversation.composer.dock` — the control itself: one power icon that
6
+ * expands inline into an Action drop-down, a Grace drop-down, and Arm. While
7
+ * armed it prints nothing: a smaller lit icon plus Cancel is the whole message.
8
+ * - `shell.overlay` — the frame-wide ribbon that counts down once the task has
9
+ * finished: minutes, seconds, milliseconds, and one tiny line naming the
10
+ * action it is counting down to.
11
+ *
12
+ * State lives entirely in the Host half. Both seats read and write it through
13
+ * this package's own routes — requested document-relative, as the shipped HMR
14
+ * client requests its event stream. A failed fetch means the Host half is not
15
+ * running (the row is disabled), so both seats disappear instead of showing
16
+ * stale state.
17
+ *
18
+ * Every colour comes from a `--dsw-alias-*` theme token, so light and dark
19
+ * settings carry the plugin; semi-transparent surfaces mix a token with
20
+ * `transparent` rather than inventing a colour.
21
+ *
22
+ * @module dsh-power-after-task/client
23
+ */
24
+ window.__ModuleLoader__.load({
25
+ id: 'dsh-power-after-task',
26
+ factory(require) {
27
+ const React = require('react')
28
+ const h = React.createElement
29
+
30
+ /** Document-relative app routes (the shell may serve the page under a path). */
31
+ const STATE_ROUTE = 'power-task/state'
32
+ const ARMS_ROUTE = 'power-task/state'
33
+ const DEFAULTS_ROUTE = 'power-task/defaults'
34
+ const ARM_ROUTE = 'power-task/arm'
35
+ const CANCEL_ROUTE = 'power-task/cancel'
36
+ /** Grace choices: seconds, plus the label shown in the drop-down. */
37
+ const GRACE_CHOICES = [
38
+ [15, '15s'],
39
+ [30, '30s'],
40
+ [60, '1m'],
41
+ [120, '2m'],
42
+ [300, '5m'],
43
+ ]
44
+
45
+ /**
46
+ * Call this package's Host routes, through the shell transport when there is one.
47
+ * @param path - document-relative route.
48
+ * @param body - request body, omitted for a GET.
49
+ * @returns the parsed JSON answer.
50
+ */
51
+ function request(path, body) {
52
+ const transport = globalThis.__DSH_TRANSPORT__
53
+ const fetchImpl = transport?.fetch ?? globalThis.fetch
54
+ return Promise.resolve(fetchImpl(path, body === undefined
55
+ ? { method: 'GET' }
56
+ : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }))
57
+ .then((res) => {
58
+ if (!res.ok) throw new Error(`${path} answered ${res.status}`)
59
+ return res.json()
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Format a countdown as `m:ss.mmm`.
65
+ * @param ms - milliseconds remaining.
66
+ * @returns the clock text.
67
+ */
68
+ function clock(ms) {
69
+ const total = Math.max(0, ms)
70
+ const minutes = Math.floor(total / 60000)
71
+ const seconds = Math.floor((total % 60000) / 1000)
72
+ const millis = Math.floor(total % 1000)
73
+ return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
74
+ }
75
+
76
+ /** The power glyph, drawn in the current text colour. */
77
+ function PowerIcon(size) {
78
+ return h('svg', {
79
+ viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true,
80
+ fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round',
81
+ style: { display: 'block' },
82
+ },
83
+ h('path', { d: 'M12 3v8' }),
84
+ h('path', { d: 'M18.4 6.6a9 9 0 1 1-12.77.04' }))
85
+ }
86
+
87
+ /** Shared compact chrome for the two seats, built only from theme tokens. */
88
+ const chrome = {
89
+ appearance: 'none',
90
+ font: 'inherit',
91
+ fontSize: 11,
92
+ lineHeight: 1.4,
93
+ color: 'var(--dsw-alias-label-primary)',
94
+ background: 'var(--dsw-alias-bg-layer-1)',
95
+ border: '1px solid var(--dsw-alias-border-l1)',
96
+ borderRadius: 6,
97
+ padding: '2px 6px',
98
+ }
99
+
100
+ /**
101
+ * The composer-dock control.
102
+ * @param props - slot props (`sessionId`) plus the registration's `inject`.
103
+ * @returns the entry element.
104
+ */
105
+ function PowerAfterTask(props) {
106
+ const { sessionId, t, request: call } = props
107
+ const [open, setOpen] = React.useState(false)
108
+ const [available, setAvailable] = React.useState(true)
109
+ const [actions, setActions] = React.useState(['sleep', 'shutdown', 'restart'])
110
+ const [action, setAction] = React.useState('shutdown')
111
+ const [grace, setGrace] = React.useState(45)
112
+ const [state, setState] = React.useState({ state: 'off', running: false })
113
+ const [remaining, setRemaining] = React.useState(0)
114
+ const [failure, setFailure] = React.useState('')
115
+ const primed = React.useRef(false)
116
+
117
+ // Defaults on the first success; the loop doubles as the availability probe,
118
+ // so disabling the row removes this entry within a couple of seconds.
119
+ React.useEffect(() => {
120
+ let alive = true
121
+ const probe = () => call(DEFAULTS_ROUTE).then((info) => {
122
+ if (!alive) return
123
+ if (!info?.actions) { setAvailable(false); primed.current = false; return }
124
+ setAvailable(true)
125
+ if (primed.current) return
126
+ primed.current = true
127
+ setActions(Array.isArray(info.actions) ? info.actions : ['sleep', 'shutdown', 'restart'])
128
+ if (info.defaultAction) setAction(info.defaultAction)
129
+ if (Number.isFinite(info.graceSeconds)) setGrace(info.graceSeconds)
130
+ }, () => {
131
+ if (!alive) return
132
+ setAvailable(false)
133
+ primed.current = false
134
+ })
135
+ probe()
136
+ const timer = setInterval(probe, 2000)
137
+ return () => { alive = false; clearInterval(timer) }
138
+ }, [call])
139
+
140
+ React.useEffect(() => {
141
+ let alive = true
142
+ call(`${STATE_ROUTE}?session=${encodeURIComponent(sessionId)}`).then((info) => {
143
+ if (alive && info) setState(info)
144
+ }, () => {})
145
+ return () => { alive = false }
146
+ }, [call, sessionId])
147
+
148
+ // One tick per second while an arm exists: it refreshes Host state and the clock.
149
+ React.useEffect(() => {
150
+ if (state.state === 'off') {
151
+ setRemaining(0)
152
+ return undefined
153
+ }
154
+ const tick = () => {
155
+ setRemaining(state.endsAt ? state.endsAt - Date.now() : 0)
156
+ call(`${STATE_ROUTE}?session=${encodeURIComponent(sessionId)}`).then((info) => {
157
+ if (info) setState(info)
158
+ }, () => {})
159
+ }
160
+ tick()
161
+ const timer = setInterval(tick, 1000)
162
+ return () => clearInterval(timer)
163
+ }, [call, sessionId, state.state, state.endsAt, state.action])
164
+
165
+ /**
166
+ * Send one operation and adopt the state it reports.
167
+ * @param path - route to post to.
168
+ * @param body - request body.
169
+ */
170
+ const send = (path, body) => {
171
+ setFailure('')
172
+ call(path, body).then((info) => {
173
+ if (info) setState(info)
174
+ }, (error) => setFailure(error instanceof Error ? error.message : String(error)))
175
+ }
176
+
177
+ const label = (id) => t(`action.${id}`)
178
+ const armed = state.state !== 'off'
179
+ const counting = state.state === 'counting'
180
+ const failed = state.state === 'failed'
181
+ const accent = failed || counting
182
+ ? 'var(--dsw-alias-state-warn-primary)'
183
+ : 'var(--dsw-alias-brand-primary)'
184
+ const choices = GRACE_CHOICES.some(([seconds]) => seconds === grace)
185
+ ? GRACE_CHOICES
186
+ : [[grace, `${grace}s`], ...GRACE_CHOICES]
187
+ // Nothing is printed while armed: a smaller lit icon plus Cancel carries the
188
+ // whole message. Action and remaining time stay reachable as a hover title.
189
+ const hover = armed
190
+ ? `${label(state.action)} · ${failed ? (state.detail ?? t('failed')) : counting ? clock(remaining) : t('waiting')}`
191
+ : t('tooltip')
192
+ const shown = failure || (failed ? `${label(state.action)} · ${state.detail ?? t('failed')}` : '')
193
+
194
+ if (!sessionId || !available) return null
195
+
196
+ return h('div', {
197
+ style: { display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' },
198
+ },
199
+ h('button', {
200
+ type: 'button',
201
+ 'aria-expanded': open,
202
+ 'aria-label': armed ? `${t('title')} · ${label(state.action)}` : t('title'),
203
+ title: hover,
204
+ style: {
205
+ ...chrome,
206
+ display: 'flex',
207
+ alignItems: 'center',
208
+ padding: armed ? '2px 5px' : '3px 6px',
209
+ cursor: 'pointer',
210
+ color: armed ? accent : 'var(--dsw-alias-label-secondary)',
211
+ borderColor: armed ? accent : 'var(--dsw-alias-border-l1)',
212
+ },
213
+ onClick: () => { setFailure(''); setOpen((value) => !value) },
214
+ }, PowerIcon(armed ? 11 : 14)),
215
+ armed ? h('button', {
216
+ type: 'button',
217
+ title: hover,
218
+ style: { ...chrome, color: accent, cursor: 'pointer' },
219
+ onClick: () => send(CANCEL_ROUTE, { sessionId }),
220
+ }, t('cancel')) : null,
221
+ open ? h('select', {
222
+ title: t('actionLabel'),
223
+ disabled: armed,
224
+ value: armed ? state.action : action,
225
+ onChange: (event) => setAction(event.target.value),
226
+ style: { ...chrome, cursor: armed ? 'default' : 'pointer' },
227
+ }, actions.map((id) => h('option', { key: id, value: id }, label(id)))) : null,
228
+ open ? h('select', {
229
+ title: t('graceLabel'),
230
+ disabled: armed,
231
+ value: String(armed ? state.graceSeconds : grace),
232
+ onChange: (event) => setGrace(Number(event.target.value)),
233
+ style: { ...chrome, cursor: armed ? 'default' : 'pointer' },
234
+ }, choices.map(([seconds, text]) => h('option', { key: seconds, value: String(seconds) }, `${t('graceLabel')} ${text}`))) : null,
235
+ open && !armed ? h('button', {
236
+ type: 'button',
237
+ style: { ...chrome, color: accent, borderColor: 'var(--dsw-alias-border-l2)', cursor: 'pointer' },
238
+ onClick: () => send(ARM_ROUTE, { sessionId, action, graceSeconds: grace }),
239
+ }, t('arm')) : null,
240
+ shown ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, shown) : null)
241
+ }
242
+
243
+ /**
244
+ * The frame-wide countdown ribbon, shown only while a countdown is running
245
+ * (or while a power action reported a failure). It never blocks the page:
246
+ * only its Cancel button takes pointer events.
247
+ * @param props - the registration's `inject`.
248
+ * @returns the overlay element, or `null`.
249
+ */
250
+ function CountdownRibbon(props) {
251
+ const { t, request: call } = props
252
+ const [arms, setArms] = React.useState([])
253
+ const [live, setLive] = React.useState(true)
254
+ const [now, setNow] = React.useState(() => Date.now())
255
+ const counting = arms.find((arm) => arm.state === 'counting') ?? null
256
+ const failedArm = arms.find((arm) => arm.state === 'failed') ?? null
257
+ const ribbon = counting ?? failedArm
258
+
259
+ React.useEffect(() => {
260
+ let alive = true
261
+ const probe = () => call(ARMS_ROUTE).then((info) => {
262
+ if (!alive) return
263
+ setLive(true)
264
+ setArms(Array.isArray(info?.arms) ? info.arms : [])
265
+ }, () => {
266
+ if (!alive) return
267
+ setLive(false)
268
+ setArms([])
269
+ })
270
+ probe()
271
+ const timer = setInterval(probe, 1000)
272
+ return () => { alive = false; clearInterval(timer) }
273
+ }, [call])
274
+
275
+ // Animation frames give the millisecond digits a smooth update.
276
+ React.useEffect(() => {
277
+ if (!counting) return undefined
278
+ let frame = requestAnimationFrame(function loop() {
279
+ setNow(Date.now())
280
+ frame = requestAnimationFrame(loop)
281
+ })
282
+ return () => cancelAnimationFrame(frame)
283
+ }, [counting?.state, counting?.endsAt])
284
+
285
+ /**
286
+ * Cancel the countdown this ribbon is showing.
287
+ */
288
+ const cancel = () => {
289
+ if (!ribbon?.sessionId) return
290
+ call(CANCEL_ROUTE, { sessionId: ribbon.sessionId }).then(() => setNow(Date.now()), () => {})
291
+ }
292
+
293
+ if (!live || !ribbon) return null
294
+
295
+ const remaining = ribbon.endsAt ? Math.max(0, ribbon.endsAt - now) : 0
296
+ const accent = failedArm
297
+ ? 'var(--dsw-alias-state-error-primary)'
298
+ : 'var(--dsw-alias-state-warn-primary)'
299
+ const caption = failedArm
300
+ ? `${t(`action.${failedArm.action}`)} · ${failedArm.detail ?? t('failed')}`
301
+ : t(`until.${counting.action}`)
302
+
303
+ return h('div', {
304
+ 'aria-live': 'polite',
305
+ style: {
306
+ position: 'fixed',
307
+ inset: 0,
308
+ display: 'flex',
309
+ alignItems: 'center',
310
+ justifyContent: 'center',
311
+ pointerEvents: 'none',
312
+ zIndex: 40,
313
+ font: 'inherit',
314
+ },
315
+ },
316
+ h('div', {
317
+ style: {
318
+ width: '100%',
319
+ display: 'flex',
320
+ flexDirection: 'column',
321
+ alignItems: 'center',
322
+ gap: 6,
323
+ padding: '30px 16px',
324
+ background: 'color-mix(in srgb, var(--dsw-alias-bg-overlay) 68%, transparent)',
325
+ borderTop: '1px solid var(--dsw-alias-border-l2)',
326
+ borderBottom: '1px solid var(--dsw-alias-border-l2)',
327
+ backdropFilter: 'blur(8px)',
328
+ boxShadow: '0 0 40px color-mix(in srgb, var(--dsw-alias-bg-base) 55%, transparent)',
329
+ },
330
+ },
331
+ h('div', {
332
+ style: {
333
+ fontSize: 'clamp(54px, 13vw, 150px)',
334
+ fontWeight: 700,
335
+ lineHeight: 1,
336
+ letterSpacing: '-0.02em',
337
+ color: accent,
338
+ fontVariantNumeric: 'tabular-nums',
339
+ },
340
+ }, clock(remaining)),
341
+ h('div', {
342
+ style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', letterSpacing: '0.01em' },
343
+ }, caption),
344
+ h('button', {
345
+ type: 'button',
346
+ style: { ...chrome, pointerEvents: 'auto', color: 'var(--dsw-alias-state-error-primary)', cursor: 'pointer', marginTop: 4 },
347
+ onClick: cancel,
348
+ }, t('cancel')))
349
+ )
350
+ }
351
+
352
+ return {
353
+ inject: ['slots', 'locale'],
354
+ apply(ctx) {
355
+ const dicts = {
356
+ en: {
357
+ title: 'Power after task',
358
+ tooltip: 'Sleep, shut down, or restart this PC once the current task finishes.',
359
+ 'action.sleep': 'Sleep',
360
+ 'action.shutdown': 'Shut down',
361
+ 'action.restart': 'Restart',
362
+ 'until.sleep': 'Until sleep',
363
+ 'until.shutdown': 'Until shut down',
364
+ 'until.restart': 'Until restart',
365
+ arm: 'Arm',
366
+ cancel: 'Cancel',
367
+ waiting: 'waiting for the task to finish',
368
+ failed: 'the power action failed',
369
+ actionLabel: 'Action',
370
+ graceLabel: 'Grace',
371
+ },
372
+ fa: {
373
+ title: 'خاموش‌شدن پس از تسک',
374
+ tooltip: 'پس از پایان تسک جاری، این رایانه را به حالت خواب ببرد، خاموش کند یا راه‌اندازی مجدد کند.',
375
+ 'action.sleep': 'خواب',
376
+ 'action.shutdown': 'خاموش',
377
+ 'action.restart': 'راه‌اندازی مجدد',
378
+ 'until.sleep': 'تا خواب',
379
+ 'until.shutdown': 'تا خاموشی',
380
+ 'until.restart': 'تا راه‌اندازی مجدد',
381
+ arm: 'آماده‌سازی',
382
+ cancel: 'لغو',
383
+ waiting: 'در انتظار پایان تسک',
384
+ failed: 'کنش برقی انجام نشد',
385
+ actionLabel: 'کنش',
386
+ graceLabel: 'مهلت',
387
+ },
388
+ }
389
+ return ctx.effect(() => {
390
+ const disposers = [
391
+ ctx.locale.register('power-after-task', 'en', dicts.en),
392
+ ctx.locale.register('power-after-task', 'fa', dicts.fa),
393
+ ]
394
+ const translate = ctx.locale.bind('power-after-task')
395
+ /**
396
+ * Translate one key, falling back to the English dictionary when the
397
+ * registry has no entry for it, so the control never prints a raw key.
398
+ * @param key - dictionary key.
399
+ * @returns the text to show.
400
+ */
401
+ const t = (key) => {
402
+ const value = translate(key)
403
+ return typeof value === 'string' && value !== key ? value : (dicts.en[key] ?? key)
404
+ }
405
+ const injected = () => ({ t, request })
406
+ disposers.push(ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
407
+ name: 'conversation.composer.dock',
408
+ id: 'power-after-task',
409
+ order: 2,
410
+ label: () => t('title'),
411
+ inject: injected,
412
+ }, PowerAfterTask)))
413
+ disposers.push(ctx.slots.inject('shell.overlay', () => ctx.slots.register({
414
+ name: 'shell.overlay',
415
+ id: 'power-after-task-countdown',
416
+ order: 20,
417
+ label: () => t('title'),
418
+ inject: injected,
419
+ }, CountdownRibbon)))
420
+ return () => { for (const dispose of disposers) dispose() }
421
+ }, 'power-after-task: locale, dock control, and countdown ribbon')
422
+ },
423
+ }
424
+ },
425
+ })
@@ -0,0 +1,12 @@
1
+ - insert:
2
+ - id: power-after-task
3
+ name: 'dsh-power-after-task'
4
+ # Installed but off: nothing runs, and no dock entry appears, until the
5
+ # row is enabled in Settings → Plugins → “Power after task”.
6
+ disabled: true
7
+ config:
8
+ # One of 'sleep', 'shutdown', 'restart' — the action the control preselects.
9
+ defaultAction: shutdown
10
+ # Grace period, in seconds, between the task finishing and the power action running.
11
+ # The countdown can be cancelled while it runs.
12
+ graceSeconds: 45
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Power after task">
2
+ <circle cx="32" cy="32" r="28" fill="#1f2937"/>
3
+ <circle cx="32" cy="32" r="28" fill="none" stroke="#247bbf" stroke-width="2"/>
4
+ <path d="M32 16v16" fill="none" stroke="#7fc6ff" stroke-width="5" stroke-linecap="round"/>
5
+ <path d="M44.97 23.03a18.4 18.4 0 1 1-25.94 0" fill="none" stroke="#7fc6ff" stroke-width="5" stroke-linecap="round"/>
6
+ <path d="M46 44a7 7 0 0 1-8.6-8.6A7 7 0 0 0 46 44z" fill="#f0b429"/>
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Host half of `dsh-power-after-task`.
3
+ *
4
+ * One operation, two callers (see the plugin-development practice notes):
5
+ * `arm`, `cancel`, `status`, and `execute` are implemented once here and reach
6
+ * the page through a plugin-owned HTTP route, while the `power_after_task`
7
+ * tool exposes the same four operations to the agent.
8
+ *
9
+ * A request is armed for one session. The countdown starts when that session's
10
+ * Agent stops running; if the Agent starts running again the countdown returns
11
+ * to waiting, so a goal driver or a follow-up message never powers the machine
12
+ * out from under the work.
13
+ *
14
+ * Requires the Web composition (`ctx.webServer`) and runs on Windows only.
15
+ *
16
+ * @module dsh-power-after-task
17
+ */
18
+ import { spawn } from 'node:child_process'
19
+
20
+ /** Power targets this build can drive. */
21
+ const POWER_ACTIONS = ['sleep', 'shutdown', 'restart']
22
+ /** Used when the row's `config` supplies no usable grace period. */
23
+ const FALLBACK_GRACE_SECONDS = 45
24
+ /** Upper bound on a countdown, so a bad argument cannot schedule a year of waiting. */
25
+ const MAX_GRACE_SECONDS = 3600
26
+ /** Body ceiling for the plugin route. */
27
+ const MAX_BODY_BYTES = 8 * 1024
28
+ /** One prefix route owns every endpoint below. */
29
+ const ROUTE = '/power-task'
30
+
31
+ /**
32
+ * PowerShell that suspends the machine through `kernel32!ApplicationSuspend`.
33
+ * `rundll32 powrprof.dll,SetSuspendState` hibernates whenever hibernation is
34
+ * enabled, so it only serves as the fallback below.
35
+ */
36
+ const SLEEP_SCRIPT = [
37
+ "Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public static class DshPower { [DllImport(\"kernel32.dll\", SetLastError = true)] public static extern bool ApplicationSuspend(); }' -Name DshPower -Namespace Dsh",
38
+ 'if ([Dsh.DshPower]::ApplicationSuspend()) { exit 0 } else { exit 1 }',
39
+ ].join('\n')
40
+
41
+ /**
42
+ * Run one program to completion.
43
+ * @param command - executable to spawn.
44
+ * @param args - its argument vector.
45
+ * @returns a promise that resolves on exit code 0.
46
+ */
47
+ function runCommand(command, args) {
48
+ return new Promise((resolve, reject) => {
49
+ const child = spawn(command, args, { stdio: 'ignore', windowsHide: true })
50
+ child.once('error', (error) => reject(new Error(`${command}: ${error.message}`)))
51
+ child.once('exit', (code, signal) => {
52
+ if (code === 0) resolve()
53
+ else reject(new Error(`${command} ${args.join(' ')} exited (${code ?? signal ?? 'unknown'})`))
54
+ })
55
+ })
56
+ }
57
+
58
+ /**
59
+ * Ask Windows to carry out one power action.
60
+ * @param action - `sleep`, `shutdown`, or `restart`.
61
+ * @returns a promise that rejects with the reason the machine stayed up.
62
+ */
63
+ async function runPowerAction(action) {
64
+ if (process.platform !== 'win32') {
65
+ throw new Error(`power-after-task drives Windows; this Host runs on ${process.platform}`)
66
+ }
67
+ if (!POWER_ACTIONS.includes(action)) throw new Error(`unknown power action '${action}'`)
68
+ if (action === 'shutdown') return runCommand('shutdown.exe', ['/s', '/t', '0', '/c', 'DSH power-after-task'])
69
+ if (action === 'restart') return runCommand('shutdown.exe', ['/r', '/t', '0', '/c', 'DSH power-after-task'])
70
+
71
+ const failures = []
72
+ try {
73
+ return await runCommand('powershell.exe', [
74
+ '-NoProfile',
75
+ '-NonInteractive',
76
+ '-ExecutionPolicy',
77
+ 'Bypass',
78
+ '-Command',
79
+ SLEEP_SCRIPT,
80
+ ])
81
+ } catch (error) {
82
+ failures.push(error instanceof Error ? error.message : String(error))
83
+ }
84
+ try {
85
+ return await runCommand('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'])
86
+ } catch (error) {
87
+ failures.push(error instanceof Error ? error.message : String(error))
88
+ }
89
+ throw new Error(`sleep failed: ${failures.join(' ; ')}`)
90
+ }
91
+
92
+ /**
93
+ * Read a request body as JSON.
94
+ * @param req - the incoming request.
95
+ * @returns the parsed object, or `{}` for an empty body.
96
+ */
97
+ async function readJsonBody(req) {
98
+ const chunks = []
99
+ let size = 0
100
+ for await (const chunk of req) {
101
+ size += chunk.length
102
+ if (size > MAX_BODY_BYTES) throw new Error('request body too large')
103
+ chunks.push(chunk)
104
+ }
105
+ if (!chunks.length) return {}
106
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
107
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('body must be a JSON object')
108
+ return parsed
109
+ }
110
+
111
+ /** Services this Host half needs from the composition. */
112
+ export const inject = ['webServer', 'timer', 'tools', 'sessionController']
113
+
114
+ export function apply(ctx, config) {
115
+ const defaultAction = POWER_ACTIONS.includes(config?.defaultAction) ? config.defaultAction : 'shutdown'
116
+ const configuredGrace = Number(config?.graceSeconds)
117
+ const defaultGrace = Number.isFinite(configuredGrace) && configuredGrace >= 0
118
+ ? Math.min(configuredGrace, MAX_GRACE_SECONDS)
119
+ : FALLBACK_GRACE_SECONDS
120
+ /** This server's own origin, read lazily so activation order cannot break it. */
121
+ const serverOrigin = () => {
122
+ try {
123
+ return new URL(ctx.webServer.getOrigin())
124
+ } catch {
125
+ return undefined
126
+ }
127
+ }
128
+
129
+ /** Sessions whose Agent is currently running. */
130
+ const running = new Set()
131
+ /** One entry per armed session: `{ action, graceSeconds, endsAt, cancelTimer }`. */
132
+ const pending = new Map()
133
+
134
+ const log = (message) => ctx.logger?.info?.(`power-after-task: ${message}`)
135
+
136
+ /** Diagnostic from the last running-state read, surfaced by `/power-task/debug`. */
137
+ let seedFailure = ''
138
+ /** Headers the last mutating request carried, so a rejected Arm is diagnosable. */
139
+ let lastPost = null
140
+ let postAttempts = 0
141
+ /** Headers the last read request carried: the page polls here every couple of seconds. */
142
+ let lastGet = null
143
+ let getAttempts = 0
144
+
145
+ /**
146
+ * Ask the Session domain which Agents are running, so an arm made before this
147
+ * plugin saw a session's first status event is still correct.
148
+ * @param sessionId - one session to refresh, omitted to refresh every row.
149
+ */
150
+ async function refreshRunning(sessionId) {
151
+ try {
152
+ const value = await ctx.sessionController.list({}, new AbortController().signal)
153
+ for (const item of value?.items ?? []) {
154
+ if (sessionId && item.sessionId !== sessionId) continue
155
+ if (item.running) running.add(item.sessionId)
156
+ else running.delete(item.sessionId)
157
+ }
158
+ seedFailure = ''
159
+ } catch (error) {
160
+ seedFailure = error instanceof Error ? error.message : String(error)
161
+ // The status-event feed remains authoritative when the read is unavailable.
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Stop a running countdown without forgetting the arm.
167
+ * @param entry - the pending entry to pause.
168
+ */
169
+ function pauseCountdown(entry) {
170
+ entry.cancelTimer?.()
171
+ entry.cancelTimer = undefined
172
+ entry.endsAt = undefined
173
+ }
174
+
175
+ /**
176
+ * Carry out the armed action. The arm stays visible until the command runs
177
+ * successfully or the user cancels it: a command that fails leaves the entry
178
+ * in the `failed` state instead of silently returning to normal.
179
+ * @param sessionId - the session whose arm fired.
180
+ */
181
+ function fire(sessionId) {
182
+ const entry = pending.get(sessionId)
183
+ if (!entry) return
184
+ pauseCountdown(entry)
185
+ log(`${entry.action} scheduled for session ${sessionId}`)
186
+ runPowerAction(entry.action).then(
187
+ () => {
188
+ pending.delete(sessionId)
189
+ log(`${entry.action} executed`)
190
+ },
191
+ (error) => {
192
+ entry.failed = error instanceof Error ? error.message : String(error)
193
+ log(`${entry.action} failed: ${entry.failed}`)
194
+ },
195
+ )
196
+ }
197
+
198
+ /**
199
+ * Begin (or restart) the grace countdown for one armed entry.
200
+ * @param sessionId - the session to count down for.
201
+ * @param entry - its pending entry.
202
+ */
203
+ function startCountdown(sessionId, entry) {
204
+ pauseCountdown(entry)
205
+ entry.endsAt = Date.now() + entry.graceSeconds * 1000
206
+ entry.cancelTimer = ctx.timer.timeout(() => fire(sessionId), entry.graceSeconds * 1000)
207
+ }
208
+
209
+ /**
210
+ * Arm one session for a power action.
211
+ * @param sessionId - the session to watch.
212
+ * @param action - `sleep`, `shutdown`, or `restart`.
213
+ * @param graceSeconds - countdown length after the task finishes.
214
+ * @returns the state the caller can display.
215
+ */
216
+ async function arm(sessionId, action = defaultAction, graceSeconds = defaultGrace) {
217
+ if (!POWER_ACTIONS.includes(action)) throw new Error(`unknown power action '${action}'`)
218
+ const grace = Math.min(Math.max(Number(graceSeconds) || 0, 0), MAX_GRACE_SECONDS)
219
+ const entry = { action, graceSeconds: grace, endsAt: undefined, cancelTimer: undefined }
220
+ pending.set(sessionId, entry)
221
+ // A session this plugin never saw start can still be mid-turn, so check the
222
+ // Session domain before concluding that its task is already finished.
223
+ if (!running.has(sessionId)) await refreshRunning(sessionId)
224
+ // An idle session is already "finished", so the countdown begins at once;
225
+ // a message sent inside the grace window puts it back to waiting.
226
+ if (!running.has(sessionId)) startCountdown(sessionId, entry)
227
+ return stateOf(sessionId)
228
+ }
229
+
230
+ /**
231
+ * Forget one arm, cancelling any countdown already running.
232
+ * @param sessionId - the session to clear.
233
+ */
234
+ function cancel(sessionId) {
235
+ const entry = pending.get(sessionId)
236
+ if (entry) pauseCountdown(entry)
237
+ pending.delete(sessionId)
238
+ return stateOf(sessionId)
239
+ }
240
+
241
+ /**
242
+ * Current state of one session's arm.
243
+ * @param sessionId - the session to report.
244
+ * @returns `{ state, running, ... }`.
245
+ */
246
+ function stateOf(sessionId) {
247
+ const entry = pending.get(sessionId)
248
+ if (!entry) return { state: 'off', running: running.has(sessionId) }
249
+ return {
250
+ state: entry.failed ? 'failed' : entry.endsAt ? 'counting' : 'armed',
251
+ action: entry.action,
252
+ graceSeconds: entry.graceSeconds,
253
+ endsAt: entry.endsAt ?? null,
254
+ detail: entry.failed,
255
+ running: running.has(sessionId),
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Whether a host value names this machine. A rebinding attack reaches the
261
+ * port under a foreign name, so the Host header is what separates the page
262
+ * from an outside web page.
263
+ * @param value - a `Host` or URL hostname, with or without a port.
264
+ * @returns whether it is a loopback name.
265
+ */
266
+ function isLoopbackHost(value) {
267
+ const host = String(value ?? '').toLowerCase()
268
+ if (!host) return false
269
+ const closing = host.lastIndexOf(']')
270
+ const bare = closing > 0 ? host.slice(1, closing) : host.split(':')[0]
271
+ return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'
272
+ }
273
+
274
+ /**
275
+ * Refuse a read request that proves it came from elsewhere.
276
+ * @param req - the incoming request.
277
+ * @returns whether the request may be served.
278
+ */
279
+ function isSameOrigin(req) {
280
+ const site = req.headers['sec-fetch-site']
281
+ if (typeof site === 'string') return site === 'same-origin' || site === 'none'
282
+ const origin = req.headers['origin']
283
+ if (typeof origin !== 'string') return true
284
+ try {
285
+ return isLoopbackHost(new URL(origin).hostname)
286
+ } catch {
287
+ return false
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Whether a mutating request is trusted. A request is refused only when it
293
+ * proves it is not the page: addressed to a non-loopback host (DNS
294
+ * rebinding or a foreign vhost), reported by the browser as cross-site, or
295
+ * sent with a non-loopback `Origin`. A request carrying none of that
296
+ * evidence passes: it is the Harness page through its own transport, or a
297
+ * local process — which could run `shutdown.exe` directly anyway.
298
+ * @param req - the incoming request.
299
+ * @returns whether Arm or Cancel may run.
300
+ */
301
+ function fromPage(req) {
302
+ const host = req.headers.host
303
+ if (typeof host === 'string' && !isLoopbackHost(host)) return false
304
+ const site = req.headers['sec-fetch-site']
305
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false
306
+ const origin = req.headers['origin']
307
+ if (typeof origin === 'string') {
308
+ try {
309
+ if (!isLoopbackHost(new URL(origin).hostname)) return false
310
+ } catch {
311
+ return false
312
+ }
313
+ }
314
+ return true
315
+ }
316
+
317
+ /**
318
+ * Answer one plugin-route request.
319
+ * @param req - the incoming request.
320
+ * @param res - the response to own.
321
+ */
322
+ async function handle(req, res) {
323
+ const reply = (status, body) => {
324
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
325
+ res.end(JSON.stringify(body))
326
+ }
327
+ if (!isSameOrigin(req)) return reply(403, { error: 'this endpoint is only reachable from the Harness page' })
328
+
329
+ const path = (req.url ?? '').split('?')[0]
330
+ const sub = path.slice(ROUTE.length).replace(/^\//, '')
331
+ try {
332
+ if (sub === 'debug') return reply(200, {
333
+ marker: 'guard-v6',
334
+ platform: process.platform,
335
+ running: [...running],
336
+ pending: [...pending.keys()],
337
+ seedFailure,
338
+ guard: 'refuse non-loopback host, cross-site, or non-loopback origin',
339
+ serverOrigin: serverOrigin() ?? null,
340
+ postAttempts,
341
+ lastPost,
342
+ getAttempts,
343
+ lastGet,
344
+ })
345
+ if (sub === 'defaults') return reply(200, { actions: POWER_ACTIONS, defaultAction, graceSeconds: defaultGrace })
346
+ if (req.method === 'GET' && (sub === 'defaults' || sub === 'state')) {
347
+ getAttempts += 1
348
+ lastGet = {
349
+ at: new Date().toISOString(),
350
+ path,
351
+ host: req.headers.host ?? null,
352
+ secFetchSite: req.headers['sec-fetch-site'] ?? null,
353
+ origin: req.headers['origin'] ?? null,
354
+ cookie: Boolean(req.headers.cookie),
355
+ }
356
+ }
357
+ if (req.method === 'GET' && sub === 'state') {
358
+ const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('session')
359
+ return reply(200, query ? stateOf(query) : {
360
+ arms: [...pending.keys()].map((id) => ({ sessionId: id, ...stateOf(id) })),
361
+ })
362
+ }
363
+ if (req.method !== 'POST') return reply(405, { error: `unsupported method ${req.method ?? 'unknown'}` })
364
+ postAttempts += 1
365
+ const accepted = fromPage(req)
366
+ lastPost = {
367
+ at: new Date().toISOString(),
368
+ path,
369
+ host: req.headers.host ?? null,
370
+ secFetchSite: req.headers['sec-fetch-site'] ?? null,
371
+ origin: req.headers['origin'] ?? null,
372
+ contentType: req.headers['content-type'] ?? null,
373
+ accepted,
374
+ }
375
+ // Arming and cancelling are mutating: they refuse a request that proves it
376
+ // is not this page.
377
+ if (!accepted) {
378
+ return reply(403, { error: 'this control only accepts requests from the Harness page', hint: 'see /power-task/debug' })
379
+ }
380
+
381
+ const body = await readJsonBody(req)
382
+ if (sub === 'arm') {
383
+ const sessionId = String(body.sessionId ?? '')
384
+ if (!sessionId) return reply(400, { error: 'sessionId is required' })
385
+ return reply(200, await arm(sessionId, body.action ?? defaultAction, body.graceSeconds ?? defaultGrace))
386
+ }
387
+ if (sub === 'cancel') {
388
+ const sessionId = String(body.sessionId ?? '')
389
+ if (!sessionId) return reply(400, { error: 'sessionId is required' })
390
+ return reply(200, cancel(sessionId))
391
+ }
392
+ return reply(404, { error: `unknown power-task endpoint '${sub}'` })
393
+ } catch (error) {
394
+ return reply(400, { error: error instanceof Error ? error.message : String(error) })
395
+ }
396
+ }
397
+
398
+ return ctx.effect(() => {
399
+ const disposer = ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler: handle })
400
+ // A session already running when this plugin activates never emits a status
401
+ // event this plugin observes, so seed the running set once.
402
+ void refreshRunning()
403
+
404
+ ctx.on('api-session/status', (sessionId, isRunning) => {
405
+ const entry = pending.get(sessionId)
406
+ if (isRunning) {
407
+ running.add(sessionId)
408
+ if (entry?.endsAt) pauseCountdown(entry)
409
+ } else {
410
+ running.delete(sessionId)
411
+ if (entry && !entry.endsAt && !entry.failed) startCountdown(sessionId, entry)
412
+ }
413
+ })
414
+
415
+ // A session leaving the live registry has no further work to protect.
416
+ ctx.on('api-session/removed', (sessionId) => {
417
+ running.delete(sessionId)
418
+ const entry = pending.get(sessionId)
419
+ if (entry && !entry.endsAt && !entry.failed) startCountdown(sessionId, entry)
420
+ })
421
+
422
+ ctx.tools.register({
423
+ name: 'power_after_task',
424
+ description:
425
+ 'Arm, cancel, or inspect a Windows power action that runs when a session’s task finishes, '
426
+ + 'or run one immediately. Arming starts a cancellable countdown after the Agent stops running.',
427
+ parameters: {
428
+ type: 'object',
429
+ properties: {
430
+ action: { type: 'string', enum: ['status', 'arm', 'cancel', 'execute'], description: 'Which operation to run.' },
431
+ session: { type: 'string', description: 'Session id to arm or cancel; defaults to the calling session.' },
432
+ power: { type: 'string', enum: POWER_ACTIONS, description: `Power target; defaults to '${defaultAction}'.` },
433
+ grace_seconds: { type: 'number', description: `Countdown seconds after the task finishes; defaults to ${defaultGrace}.` },
434
+ },
435
+ required: ['action'],
436
+ additionalProperties: false,
437
+ },
438
+ output: {
439
+ schema: { type: 'object', properties: { state: { type: 'string' }, detail: { type: 'string' } } },
440
+ render: (args, value) => [{ type: 'text', text: JSON.stringify(value ?? {}, null, 2) }],
441
+ },
442
+ execute: async (args, exec) => {
443
+ const sessionId = args.session ?? exec?.agent?.id
444
+ try {
445
+ if (args.action === 'status') return sessionId ? stateOf(sessionId) : { state: 'off', running: false }
446
+ if (args.action === 'arm') {
447
+ if (!sessionId) throw new Error('no session to arm')
448
+ return await arm(sessionId, args.power ?? defaultAction, args.grace_seconds ?? defaultGrace)
449
+ }
450
+ if (args.action === 'cancel') {
451
+ if (!sessionId) throw new Error('no session to cancel')
452
+ return cancel(sessionId)
453
+ }
454
+ if (args.action === 'execute') {
455
+ await runPowerAction(args.power ?? defaultAction)
456
+ return { state: 'executed', detail: String(args.power ?? defaultAction) }
457
+ }
458
+ throw new Error(`unknown action '${args.action}'`)
459
+ } catch (error) {
460
+ return { state: 'error', detail: error instanceof Error ? error.message : String(error) }
461
+ }
462
+ },
463
+ })
464
+
465
+ return () => {
466
+ disposer()
467
+ for (const entry of pending.values()) pauseCountdown(entry)
468
+ pending.clear()
469
+ running.clear()
470
+ }
471
+ }, 'power-after-task: route, events, and tool')
472
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Power after task",
4
+ "description": "Sleep, shut down, or restart this Windows PC after the current task finishes, with a cancellable grace countdown in the composer dock and a full-width countdown ribbon. Off by default: enable this row here."
5
+ }
6
+ }
package/locale/fa.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "خاموش‌شدن پس از تسک",
4
+ "description": "این رایانه ویندوزی را پس از پایان تسک جاری به حالت خواب ببرد، خاموش کند یا راه‌اندازی مجدد کند؛ با شمارش معکوس قابل لغو در نوار زیر پیام‌نویس و روبان شمارش معکوس روی صفحه. به‌طور پیش‌فرض خاموش است: این ردیف را اینجا فعال کنید."
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "dsh-power-after-task",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "DeepSeek Harness (DSH) plugin for Windows: sleep, shut down, or restart the PC once the current agent task finishes. Ships disabled; arm it from a compact composer-dock control, watch a frame-wide countdown ribbon you can cancel any time. No dependencies, no build step.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "deepseek",
8
+ "harness",
9
+ "dsh",
10
+ "cordis",
11
+ "cordis-plugin",
12
+ "plugin",
13
+ "bundle",
14
+ "web-plugin",
15
+ "windows",
16
+ "power",
17
+ "power-management",
18
+ "shutdown",
19
+ "restart",
20
+ "sleep",
21
+ "hibernate",
22
+ "suspend",
23
+ "agent-task",
24
+ "countdown"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "Mori",
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org/",
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/white-sparrow/dsh-power-after-task.git"
35
+ },
36
+ "homepage": "https://github.com/white-sparrow/dsh-power-after-task#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/white-sparrow/dsh-power-after-task/issues"
39
+ },
40
+ "type": "module",
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "scripts": {
45
+ "check": "node --check index.js && node --check client.js",
46
+ "prepublishOnly": "npm run check",
47
+ "prepack": "npm run check",
48
+ "stage": "node scripts/npm-auth.mjs stage publish",
49
+ "stage:list": "node scripts/npm-auth.mjs stage list",
50
+ "stage:view": "node scripts/npm-auth.mjs stage view",
51
+ "stage:approve": "node scripts/npm-auth.mjs stage approve",
52
+ "stage:reject": "node scripts/npm-auth.mjs stage reject",
53
+ "stage:download": "node scripts/npm-auth.mjs stage download",
54
+ "publish:local": "node scripts/npm-auth.mjs publish"
55
+ },
56
+ "exports": {
57
+ ".": "./index.js",
58
+ "./client": "./client.js",
59
+ "./locale/*.json": "./locale/*.json",
60
+ "./icon": "./icon.svg"
61
+ },
62
+ "files": [
63
+ "index.js",
64
+ "client.js",
65
+ "cordis.patch.yml",
66
+ "locale/*.json",
67
+ "icon.svg",
68
+ "README.md",
69
+ "LICENSE",
70
+ "CHANGELOG.md"
71
+ ],
72
+ "dsh": {
73
+ "bundle": { "patch": "./cordis.patch.yml" },
74
+ "client": {
75
+ "platform": "web",
76
+ "immediately": true,
77
+ "inject": ["@deepseek-ai/dsh-client-ui-conversation"]
78
+ }
79
+ }
80
+ }