dsh-power-after-task 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +144 -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 +69 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `dsh-power-after-task` are documented here.
|
|
4
|
+
|
|
5
|
+
## 1.0.0
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
- Arm one Windows power action per session — `sleep`, `shutdown`, or `restart` — and run it when that session's task finishes.
|
|
10
|
+
- 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.
|
|
11
|
+
- Cancellable grace countdown between completion and the power command, defaulting to 45 seconds and capped at 1 hour.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- Enabling or disabling the plugin row adds or removes the dock entry live — the widget probes this package's route.
|
|
16
|
+
- All colours come from `--dsw-alias-*` theme tokens, so the UI follows the Harness light/dark setting.
|
|
17
|
+
- Plugin row ships `disabled: true`: enable it in Settings → Plugins when you need it.
|
|
18
|
+
- 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.
|
|
19
|
+
- `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.
|
|
20
|
+
- `power_after_task` agent tool: `status`, `arm`, `cancel`, `execute`.
|
|
21
|
+
- English and Persian locale dictionaries plus Plugin Manager title, description, and icon.
|
|
22
|
+
- Local publish helper: `npm run publish:local` reads `NPM_TOKEN` from a gitignored `.env`, publishes through a throwaway owner-only npmrc, and deletes it afterwards.
|
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,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-power-after-task
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A DeepSeek Harness (DSH) plugin that puts the host Windows PC to **Sleep**, **Shuts it down**, or **Restarts** it once the current agent task finishes — with a cancellable grace countdown, controlled from one small button in the composer dock.
|
|
4
|
+
|
|
5
|
+
It never acts on its own. Nothing happens until you press **Arm**, and the power command only runs after the session's agent has actually stopped working *and* the grace countdown has run out.
|
|
6
|
+
|
|
7
|
+
**Repository:** <https://github.com/white-sparrow/dsh-power-after-task>
|
|
8
|
+
|
|
9
|
+
## What counts as "the task finished"
|
|
10
|
+
|
|
11
|
+
The task is the span of work between your prompt and the moment the agent stops producing work — however many steps (tool calls), turns, continuations, or subagents it took. It ends on any turn stop:
|
|
12
|
+
|
|
13
|
+
| Situation | Treated as finished |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| The agent answered and stopped | yes |
|
|
16
|
+
| The agent stopped with an error and showed no result | yes |
|
|
17
|
+
| You cancelled the run | yes |
|
|
18
|
+
| A goal driver or auto-continuation is still running the session | no |
|
|
19
|
+
| You send another message while the countdown is running | no — the countdown goes back to waiting |
|
|
20
|
+
| The session is archived or removed while armed | yes |
|
|
21
|
+
|
|
22
|
+
The plugin watches the Harness session-status signal (`api-session/status`), so it follows the real agent lifecycle rather than a guess about "the answer being visible".
|
|
23
|
+
|
|
24
|
+
## Arm and Grace
|
|
25
|
+
|
|
26
|
+
- **Arm** — registers the request for one session: "when this session's task finishes, run the chosen power action". Until you Arm, the plugin is inert.
|
|
27
|
+
- **Grace** — a cancellable delay between "the task finished" and "the command runs" (default 45 seconds). It exists so a mis-click, a late follow-up message, or unsaved work can still be rescued. Choices: 15s, 30s, 1m, 2m, 5m.
|
|
28
|
+
- **Cancel** — stops the arm at any time, while waiting or while counting.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Arm → waiting for the task to finish → agent goes idle → grace countdown → power command
|
|
32
|
+
↑ any new activity returns here │ Cancel → off
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Commands actually run: `shutdown.exe /s /t 0` (Shut down), `shutdown.exe /r /t 0` (Restart), or `kernel32!ApplicationSuspend` via PowerShell with a `rundll32 powrprof.dll,SetSuspendState 0,0,0` fallback (Sleep). Sleep uses `ApplicationSuspend` deliberately: the `rundll32` form hibernates whenever hibernation is enabled.
|
|
36
|
+
|
|
37
|
+
## The control in the UI
|
|
38
|
+
|
|
39
|
+
### Composer-dock control
|
|
40
|
+
|
|
41
|
+
One entry in the composer dock, beside the shipped Activity and Usage entries:
|
|
42
|
+
|
|
43
|
+
- **Idle** — a single power icon. Nothing else.
|
|
44
|
+
- **Click** — expands inline into two drop-downs (Action, Grace) and an **Arm** button.
|
|
45
|
+
- **Armed** — the icon shrinks, lights up (brand colour while waiting, amber while counting or failed), and a **Cancel** button appears. No status text is printed; hovering the icon shows the action and the remaining countdown.
|
|
46
|
+
- **Disabled row** — the entry removes itself within a couple of seconds, whether you enable or disable it, because the widget probes the plugin route.
|
|
47
|
+
|
|
48
|
+
### Countdown ribbon
|
|
49
|
+
|
|
50
|
+
While a countdown is running, a wide semi-transparent ribbon sits across the middle of the frame (registered in `shell.overlay`):
|
|
51
|
+
|
|
52
|
+
- A large `m:ss.mmm` timer — minutes, seconds, milliseconds — updated on animation frames.
|
|
53
|
+
- A tiny line under it naming the target: `Until sleep`, `Until shut down`, `Until restart`.
|
|
54
|
+
- A **Cancel** button, because the arm stays visible until the countdown finishes or you cancel it.
|
|
55
|
+
|
|
56
|
+
The layer itself is click-through: only the Cancel button takes pointer events, so the ribbon never blocks the work you are still doing. A failed power command keeps the ribbon visible with the error instead of silently returning to normal.
|
|
57
|
+
|
|
58
|
+
Every colour is a `--dsw-alias-*` theme token (`bg-overlay`, `brand-primary`, `state-warn-primary`, `state-error-primary`, `label-*`, `border-*`), mixed with `transparent` for the semi-transparent surfaces, so the plugin follows the Harness light/dark setting automatically.
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- **Windows** host (the power commands are Windows-specific). On other platforms the action is rejected with an error.
|
|
63
|
+
- **Web composition** (`dsh web`): the dock widget talks to the Host through this plugin's own HTTP route. A Desktop/Electron page has no `ctx.webServer`, so the control does not appear there.
|
|
64
|
+
- Node 20+ for the Harness Host.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
Publish or unpack the package, then install the bundle in the Harness profile:
|
|
69
|
+
|
|
70
|
+
- **Plugin Manager UI** → Install bundle → `dsh-power-after-task`.
|
|
71
|
+
- Or from an agent session, ask it to install the bundle with the `plugin_manager` tool.
|
|
72
|
+
- Or from a checkout: `plugin_manager` install target pointing at the directory (pnpm links it, no build step — the package ships plain JavaScript).
|
|
73
|
+
|
|
74
|
+
The bundle ships a composition patch (`dsh.bundle.patch`) that inserts the plugin row. Installing does **not** turn it on.
|
|
75
|
+
|
|
76
|
+
## Enable it when you need it
|
|
77
|
+
|
|
78
|
+
The row ships `disabled: true`, so after install nothing runs and the dock entry does not appear:
|
|
79
|
+
|
|
80
|
+
1. Settings → Plugins → **Power after task** → enable. The entry appears within a couple of seconds, and disabling the row removes it again — no page refresh needed either way.
|
|
81
|
+
|
|
82
|
+
Arm and Cancel are mutating, so they refuse a request that *proves* it is not this page: a non-loopback `Host` (DNS rebinding or a foreign vhost), a browser-reported cross-site request (`Sec-Fetch-Site`), or a non-loopback `Origin`. A request carrying none of that evidence is accepted — that is the Harness page through its own transport, which forwards `Cookie` but not `Sec-Fetch-*` or `Origin` — and a local process, which could run `shutdown.exe` directly anyway. A foreign web page cannot reach this port under its own name, and a DNS-rebinding attack arrives with the foreign `Host`, so both are refused. Turning the row off clears every pending arm and countdown.
|
|
83
|
+
|
|
84
|
+
`GET /power-task/debug` reports the running and armed sessions, the server origin, and the headers of the last read plus the last mutating request (`accepted` says whether the guard passed), so a rejected Arm is diagnosable without a browser console.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
The row's `config` lives in `cordis.patch.yml` inside the package; override it with your own patch if you want different defaults.
|
|
89
|
+
|
|
90
|
+
| Field | Default | Meaning |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `defaultAction` | `shutdown` | Action the control preselects: `sleep`, `shutdown`, `restart` |
|
|
93
|
+
| `graceSeconds` | `45` | Countdown length after the task finishes; capped at 3600 |
|
|
94
|
+
|
|
95
|
+
## Agent tool
|
|
96
|
+
|
|
97
|
+
The plugin also registers one tool, so you can ask your agent to do the same thing:
|
|
98
|
+
|
|
99
|
+
`power_after_task` — `action: status | arm | cancel | execute`, `session`, `power: sleep | shutdown | restart`, `grace_seconds`.
|
|
100
|
+
|
|
101
|
+
`execute` runs the action immediately and is deliberately **not** reachable from the HTTP route: the page can only Arm and Cancel.
|
|
102
|
+
|
|
103
|
+
## Files
|
|
104
|
+
|
|
105
|
+
| File | Role |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `index.js` | Host half: `/power-task` route, session-status watcher, countdown, Windows power commands, `power_after_task` tool |
|
|
108
|
+
| `client.js` | Web UI half: the composer-dock control and the `shell.overlay` countdown ribbon |
|
|
109
|
+
| `cordis.patch.yml` | The plugin row, disabled by default, with its `config` |
|
|
110
|
+
| `locale/en.json`, `locale/fa.json` | Plugin Manager title and description (`meta.title`, `meta.description`) |
|
|
111
|
+
| `icon.svg` | Plugin Manager artwork |
|
|
112
|
+
|
|
113
|
+
No build step, no dependencies.
|
|
114
|
+
|
|
115
|
+
## Troubleshooting
|
|
116
|
+
|
|
117
|
+
| Symptom | Fix |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| No dock entry | The row is disabled — enable it in Settings → Plugins; the entry appears within a couple of seconds |
|
|
120
|
+
| Arm answers `answered 403` | Open `http://127.0.0.1:<port>/power-task/debug` and read `lastPost`: it shows the `host`, `secFetchSite`, `origin` and `accepted` of your click. A non-loopback `host` means the page is being served through a proxy or a foreign hostname |
|
|
121
|
+
| Ribbon does not appear after upgrading the plugin | Refresh the page once so the browser loads the new client bundle |
|
|
122
|
+
| Ribbon keeps counting after the task should be protected | That is the design: it counts until the command runs, you Cancel it, or new activity pauses it back to waiting |
|
|
123
|
+
| `… failed` in the ribbon | The Host could not run the command — check power settings, hibernation/fast-startup, or shutdown privileges; Cancel clears the state |
|
|
124
|
+
| Nothing happens when the task ends | The session is still running (a goal continuation, a subagent, or a queued message keeps it `running`) |
|
|
125
|
+
| Control missing on the Desktop app | Expected: that composition has no web server |
|
|
126
|
+
|
|
127
|
+
## Publish to npm
|
|
128
|
+
|
|
129
|
+
Publishing needs an npm credential, and `.env` — gitignored and excluded from the tarball — is where it lives on your machine:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
NPM_TOKEN=<your npm access token>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Create it at npmjs.com → Account settings → Access tokens → **Granular Access Token**, scoped to `dsh-power-after-task` only, type **Automation**, no read access to private packages, short expiry (`.env.example` documents the format).
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npm run publish:local
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`scripts/publish.mjs` reads `NPM_TOKEN` from `.env` (or the environment), writes it into a throwaway `.npmrc` under your temp directory with owner-only access, runs `npm publish --access public` — which still runs `prepublishOnly` → `npm run check` first — and deletes that file when the publish returns. npm 10 ignores `${VAR}` interpolation inside `--userconfig`, so the value has to be written literally there. The token appears in no command line, no npm output, no commit, and no tarball; nothing publishes without it.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT — see `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,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-power-after-task",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: sleep, shut down, or restart this Windows PC after the current agent task finishes, with a cancellable grace countdown in the composer dock.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"cordis",
|
|
9
|
+
"plugin",
|
|
10
|
+
"bundle",
|
|
11
|
+
"windows",
|
|
12
|
+
"power",
|
|
13
|
+
"sleep",
|
|
14
|
+
"shutdown",
|
|
15
|
+
"restart"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Mori",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org/",
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/white-sparrow/dsh-power-after-task.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/white-sparrow/dsh-power-after-task#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/white-sparrow/dsh-power-after-task/issues"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"check": "node --check index.js && node --check client.js",
|
|
37
|
+
"prepublishOnly": "npm run check",
|
|
38
|
+
"prepack": "npm run check",
|
|
39
|
+
"stage": "node scripts/npm-auth.mjs stage publish",
|
|
40
|
+
"stage:list": "node scripts/npm-auth.mjs stage list",
|
|
41
|
+
"stage:view": "node scripts/npm-auth.mjs stage view",
|
|
42
|
+
"stage:approve": "node scripts/npm-auth.mjs stage approve",
|
|
43
|
+
"stage:reject": "node scripts/npm-auth.mjs stage reject",
|
|
44
|
+
"stage:download": "node scripts/npm-auth.mjs stage download",
|
|
45
|
+
"publish:local": "node scripts/npm-auth.mjs publish"
|
|
46
|
+
},
|
|
47
|
+
"exports": {
|
|
48
|
+
".": "./index.js",
|
|
49
|
+
"./client": "./client.js",
|
|
50
|
+
"./locale/*.json": "./locale/*.json",
|
|
51
|
+
"./icon": "./icon.svg"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"index.js",
|
|
55
|
+
"client.js",
|
|
56
|
+
"cordis.patch.yml",
|
|
57
|
+
"locale/*.json",
|
|
58
|
+
"icon.svg",
|
|
59
|
+
"README.md",
|
|
60
|
+
"LICENSE",
|
|
61
|
+
"CHANGELOG.md"
|
|
62
|
+
],
|
|
63
|
+
"dsh": {
|
|
64
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
65
|
+
"client": {
|
|
66
|
+
"platform": "web",
|
|
67
|
+
"immediately": true,
|
|
68
|
+
"inject": ["@deepseek-ai/dsh-client-ui-conversation"]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|