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 +32 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/client.js +425 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +7 -0
- package/index.js +472 -0
- package/locale/en.json +6 -0
- package/locale/fa.json +6 -0
- package/package.json +78 -4
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
|
-
#
|
|
1
|
+
# dsh-power-after-task
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-power-after-task)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|