dsh-autorestart 0.6.1 → 0.7.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/lib/access.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * dsh-autorestart — the instance record: how the running host can be reached.
3
+ *
4
+ * TWO facts make this file necessary:
5
+ *
6
+ * 1. The web server of the desktop app listens on a RANDOM port (`--port 0`),
7
+ * so the port of the PREVIOUS instance is worthless for the next one. The
8
+ * port of the new instance is only known to the new instance itself — so
9
+ * the new instance has to publish it, and the rollback watcher has to read
10
+ * it instead of guessing.
11
+ * 2. The boot token of `dsh web` is created PER PROCESS. After a restart the
12
+ * old access link answers 401, so the user is locked out of their own
13
+ * interface until somebody hands them the new link. The new instance knows
14
+ * its own link (the host's connection service signs it), so the new
15
+ * instance writes it down — into a file only the user can read.
16
+ *
17
+ * The record is ONE small JSON file in the state directory:
18
+ *
19
+ * { "bootId": "…", "pid": 1234, "port": 55537, "url": "http://…/?token=…",
20
+ * "token": "…", "at": 1730000000000 }
21
+ *
22
+ * `bootId` is what tells "the new instance" from "the old one": the plugin
23
+ * generates a new one on every boot, so a watcher can wait for the change
24
+ * instead of guessing from a port.
25
+ *
26
+ * The file holds a SECRET (the boot token), so it is written with the
27
+ * restrictive mode 0600. On POSIX that is enforced by the kernel; on Windows
28
+ * the mode is advisory, so the file inherits the ACL of the (per-user) state
29
+ * directory — which is why the token is never written anywhere else in
30
+ * readable form.
31
+ *
32
+ * ESM only, Node builtins only. No DSH / npm imports.
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+
38
+ /** The name of the instance record inside the state directory. */
39
+ export const ACCESS_FILE_NAME = 'access.json';
40
+
41
+ /** The mode the instance record is written with: readable by its owner only. */
42
+ export const ACCESS_FILE_MODE = 0o600;
43
+
44
+ /** @param {unknown} value */
45
+ function errorText(value) {
46
+ if (value && typeof value.message === 'string' && value.message) return value.message;
47
+ return String(value);
48
+ }
49
+
50
+ /**
51
+ * The path of the instance record that belongs to a state directory.
52
+ *
53
+ * @param {string} stateDir
54
+ * @returns {string}
55
+ */
56
+ export function accessFilePath(stateDir) {
57
+ return path.join(String(stateDir ?? ''), ACCESS_FILE_NAME);
58
+ }
59
+
60
+ /**
61
+ * The access link of the running instance, with its boot token when the host's
62
+ * connection service can sign one.
63
+ *
64
+ * The connection service is optional: a host that does not provide it still
65
+ * gets the plain origin, so the record is always written.
66
+ *
67
+ * @param {{ origin?: string, connection?: any }} options
68
+ * @returns {{ url: string|null, token: string|null }}
69
+ */
70
+ export function resolveAccessUrl({ origin = '', connection = null } = {}) {
71
+ const base = typeof origin === 'string' ? origin.trim().replace(/\/+$/, '') : '';
72
+ if (base === '') return { url: null, token: null };
73
+ let url = base + '/';
74
+ let token = null;
75
+ try {
76
+ if (connection && typeof connection.authenticatedUrl === 'function') {
77
+ const signed = connection.authenticatedUrl(base);
78
+ if (typeof signed === 'string' && signed.trim() !== '') {
79
+ url = signed.trim();
80
+ try {
81
+ const parsed = new URL(url);
82
+ token = parsed.searchParams.get('token');
83
+ } catch {
84
+ token = null;
85
+ }
86
+ }
87
+ }
88
+ } catch {
89
+ // A connection service that throws must not cost us the plain origin.
90
+ url = base + '/';
91
+ token = null;
92
+ }
93
+ return { url, token };
94
+ }
95
+
96
+ /**
97
+ * Write the instance record.
98
+ *
99
+ * The write is atomic (a temporary file + a rename), so a reader never sees a
100
+ * half-written record, and the mode is restricted to the owner.
101
+ *
102
+ * @param {{
103
+ * stateDir?: string,
104
+ * bootId?: string|null,
105
+ * pid?: number|null,
106
+ * port?: number|null,
107
+ * url?: string|null,
108
+ * token?: string|null,
109
+ * now?: number,
110
+ * }} [options]
111
+ * @returns {{ ok: boolean, path: string|null, record: object|null, error: string|null }}
112
+ */
113
+ export function writeAccessRecord({
114
+ stateDir = '',
115
+ bootId = null,
116
+ pid = null,
117
+ port = null,
118
+ url = null,
119
+ token = null,
120
+ now = Date.now(),
121
+ } = {}) {
122
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
123
+ if (dir === '') {
124
+ return { ok: false, path: null, record: null, error: 'writeAccessRecord requires stateDir' };
125
+ }
126
+ const file = accessFilePath(dir);
127
+ const record = {
128
+ bootId: typeof bootId === 'string' && bootId !== '' ? bootId : null,
129
+ pid: Number.isFinite(Number(pid)) ? Number(pid) : null,
130
+ port: Number.isFinite(Number(port)) ? Number(port) : null,
131
+ url: typeof url === 'string' && url !== '' ? url : null,
132
+ token: typeof token === 'string' && token !== '' ? token : null,
133
+ at: Number.isFinite(Number(now)) ? Number(now) : Date.now(),
134
+ };
135
+ try {
136
+ fs.mkdirSync(dir, { recursive: true });
137
+ const temporary = file + '.tmp';
138
+ fs.writeFileSync(temporary, JSON.stringify(record, null, 2) + '\n', {
139
+ encoding: 'utf8',
140
+ mode: ACCESS_FILE_MODE,
141
+ });
142
+ // `writeFileSync` only applies the mode when it CREATES the file, so the
143
+ // permission is set explicitly as well (and after the rename, because the
144
+ // rename keeps the mode of the source anyway).
145
+ try {
146
+ fs.chmodSync(temporary, ACCESS_FILE_MODE);
147
+ } catch {
148
+ /* the mode is advisory on some platforms */
149
+ }
150
+ fs.renameSync(temporary, file);
151
+ try {
152
+ fs.chmodSync(file, ACCESS_FILE_MODE);
153
+ } catch {
154
+ /* the mode is advisory on some platforms */
155
+ }
156
+ return { ok: true, path: file, record, error: null };
157
+ } catch (error) {
158
+ return { ok: false, path: file, record, error: 'could not write the instance record: ' + errorText(error) };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Read the instance record from a file path.
164
+ *
165
+ * @param {string} file
166
+ * @returns {{ ok: boolean, record: object|null, error: string|null }}
167
+ */
168
+ export function readAccessRecordFile(file) {
169
+ const target = typeof file === 'string' ? file.trim() : '';
170
+ if (target === '') return { ok: false, record: null, error: 'readAccessRecordFile requires a path' };
171
+ try {
172
+ const text = fs.readFileSync(target, 'utf8');
173
+ if (text.charCodeAt(0) === 0xfeff) {
174
+ const parsed = JSON.parse(text.slice(1));
175
+ return { ok: true, record: parsed && typeof parsed === 'object' ? parsed : null, error: null };
176
+ }
177
+ const parsed = JSON.parse(text);
178
+ return { ok: true, record: parsed && typeof parsed === 'object' ? parsed : null, error: null };
179
+ } catch (error) {
180
+ return { ok: false, record: null, error: errorText(error) };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Read the instance record of a state directory.
186
+ *
187
+ * @param {string} stateDir
188
+ * @returns {{ ok: boolean, record: object|null, error: string|null }}
189
+ */
190
+ export function readAccessRecord(stateDir) {
191
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
192
+ if (dir === '') return { ok: false, record: null, error: 'readAccessRecord requires stateDir' };
193
+ return readAccessRecordFile(accessFilePath(dir));
194
+ }
195
+
196
+ /**
197
+ * Remove the instance record (used when a restart is armed, so a stale record
198
+ * of a dead instance is never mistaken for a live one).
199
+ *
200
+ * @param {string} stateDir
201
+ * @returns {boolean} whether a record was removed
202
+ */
203
+ export function clearAccessRecord(stateDir) {
204
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
205
+ if (dir === '') return false;
206
+ try {
207
+ fs.rmSync(accessFilePath(dir), { force: true });
208
+ return true;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * dsh-autorestart — what counts as REAL activity for the quiet window.
3
+ *
4
+ * THE DEFECT THIS MODULE FIXES
5
+ *
6
+ * The settle window ("the host must stay quiet for N ms before a restart may
7
+ * fire") used to be reset by EVERY session event. The host emits session
8
+ * events continuously while the interface polls and synchronises (measured on
9
+ * the real machine: one every 2-3 seconds), so a window longer than the event
10
+ * interval could never fill: the restart never fired, no matter how long the
11
+ * user waited.
12
+ *
13
+ * THE POLICY
14
+ *
15
+ * A session event counts as activity only when it says that WORK happened: a
16
+ * message was written, a tool ran, a turn started or ended. Everything else
17
+ * (a title, a projection update, a context request, a job that settled, a
18
+ * permission preset) is routine and must NOT reset the window.
19
+ *
20
+ * The rule is deliberately one-sided: an event whose type cannot be read is
21
+ * NOT activity. When in doubt the plugin would rather restart a little early
22
+ * than never restart at all.
23
+ *
24
+ * The hard guarantee does not live here: a conversation that is really
25
+ * running blocks the restart through `busyAgents()` (the `running` reason),
26
+ * and that rule is never relaxed. This module only decides what the QUIET
27
+ * window measures.
28
+ *
29
+ * ESM only, Node builtins only. No DSH / npm imports.
30
+ */
31
+
32
+ /**
33
+ * The session event types that mean "work happened".
34
+ *
35
+ * The vocabulary is the one of the DSH session service (its known event
36
+ * types). Only the events that describe a message, a tool, a turn or a
37
+ * command are listed; the rest (titles, projections, jobs, presets, modes,
38
+ * schedules, telemetry) are routine.
39
+ */
40
+ export const WORK_SESSION_EVENT_TYPES = Object.freeze([
41
+ // A message was written into a conversation.
42
+ 'user/message',
43
+ 'developer/message',
44
+ 'system/message',
45
+ 'assistant/message',
46
+ 'assistant/attempt',
47
+ // A tool ran.
48
+ 'tool/call',
49
+ 'tool/result',
50
+ 'tool/ptc-dispatch',
51
+ 'tool/ptc-dispatch-start',
52
+ // A turn or a step started or ended.
53
+ 'turn/start',
54
+ 'turn/end',
55
+ 'step/start',
56
+ 'step/end',
57
+ // A command ran.
58
+ 'command/run',
59
+ 'command/done',
60
+ // Work was handed around: an inbox splice, a team message, a workflow run.
61
+ 'agent/inbox/spliced',
62
+ 'team/message/queued',
63
+ 'team/message/delivered',
64
+ 'team/task',
65
+ 'team/member',
66
+ 'tool-workflow/run-start',
67
+ 'tool-workflow/run-end',
68
+ 'tool-workflow/agent-start',
69
+ 'tool-workflow/agent-end',
70
+ // A hook ran, a compaction ran, the model retried, a goal changed.
71
+ 'hook/invoked',
72
+ 'hook/result',
73
+ 'compaction/start',
74
+ 'compaction/end',
75
+ 'compaction/prune',
76
+ 'compaction/summary',
77
+ 'llm/retry',
78
+ 'llm/retry-started',
79
+ 'goal/change',
80
+ 'approval/asked',
81
+ ])
82
+
83
+ /** The work types as a Set, for a cheap lookup. */
84
+ const WORK_TYPES = new Set(WORK_SESSION_EVENT_TYPES)
85
+
86
+ /**
87
+ * The type of one session event, or null when it cannot be read.
88
+ *
89
+ * The payload shape differs between the hosts that call this plugin, so the
90
+ * type is looked for in the places it can be: the payload itself, the nested
91
+ * event, or the data envelope.
92
+ *
93
+ * @param {any} payload the event payload the host delivered
94
+ * @returns {string|null}
95
+ */
96
+ export function sessionEventType(payload) {
97
+ if (payload === null || typeof payload !== 'object') return null
98
+ const candidates = [
99
+ payload.type,
100
+ payload.eventType,
101
+ payload.event && typeof payload.event === 'object' ? payload.event.type : undefined,
102
+ payload.data && typeof payload.data === 'object' ? payload.data.type : undefined,
103
+ payload.data && typeof payload.data === 'object' && payload.data.event && typeof payload.data.event === 'object'
104
+ ? payload.data.event.type
105
+ : undefined,
106
+ ]
107
+ for (const candidate of candidates) {
108
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim()
109
+ }
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Whether one session event means that real work happened.
115
+ *
116
+ * @param {any} payload the event payload the host delivered
117
+ * @returns {boolean} false for a routine event AND for an event whose type
118
+ * cannot be read (the one-sided rule: when in doubt, do not wait)
119
+ */
120
+ export function isWorkSessionEvent(payload) {
121
+ const type = sessionEventType(payload)
122
+ if (type === null) return false
123
+ return WORK_TYPES.has(type)
124
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * dsh-autorestart — THE "START AT LOGIN" GUIDE OF EVERY PLATFORM.
3
+ *
4
+ * A hosted web host can be started in a dozen ways, and "start at login" means
5
+ * something different on every platform. The plugin therefore hands out ONE
6
+ * piece of advice per platform, and it always says what the record really is:
7
+ *
8
+ * | platform | what the plugin does |
9
+ * |-----------------------|----------------------------------------------------------|
10
+ * | Windows | a scheduled task (`/SC ONLOGON /RL LIMITED`, no admin); when the machine refuses it, a `.cmd` in the startup folder. The command is a REAL node + supervisor.js — never the app executable. |
11
+ * | Linux (not WSL) | a systemd USER service (`systemctl --user`, no admin) — plus the `loginctl enable-linger` hint, because without the linger it only starts after a login. |
12
+ * | WSL with systemd | the same systemd user unit, plus the linger hint. |
13
+ * | WSL without systemd | NO "at login" at all: two ways out (turn systemd on inside WSL, or let Windows start the distribution). NEVER Docker/Compose. |
14
+ * | container / k8s | no promise at all: the orchestrator brings it back (compose `restart: always`). |
15
+ * | unknown | nothing is installed and nothing is guessed: the user is told why and asked to choose. |
16
+ *
17
+ * The advice is a pure function of its input, so every branch is testable
18
+ * without a machine.
19
+ *
20
+ * ESM only, Node builtins only.
21
+ */
22
+
23
+ import { HOST_KINDS } from './host-kind.js'
24
+
25
+ /** The kinds of the advice (one per platform). */
26
+ export const AUTOSTART_ADVICE_KINDS = Object.freeze({
27
+ /** Windows: a scheduled task (with the startup folder as the fallback). */
28
+ WINDOWS: 'windows-task',
29
+ /** Linux: a systemd user service (+ the linger hint). */
30
+ LINUX: 'linux-systemd',
31
+ /** WSL with systemd: the same, and the linger is the missing piece. */
32
+ WSL_SYSTEMD: 'wsl-systemd',
33
+ /** WSL without systemd: the user has to pick one of two ways. */
34
+ WSL_MANUAL: 'wsl-manual',
35
+ /** A container: the orchestrator decides. */
36
+ CONTAINER: 'container',
37
+ /** Nobody can tell: nothing is installed, nothing is guessed. */
38
+ UNKNOWN: 'unknown',
39
+ })
40
+
41
+ /** @param {unknown} value */
42
+ function text(value) {
43
+ return typeof value === 'string' ? value.trim() : ''
44
+ }
45
+
46
+ /**
47
+ * The advice of one platform.
48
+ *
49
+ * @param {{
50
+ * host?: { kind?: string, suspect?: boolean, distro?: string, evidence?: string },
51
+ * platform?: string,
52
+ * hasSystemd?: boolean,
53
+ * container?: boolean,
54
+ * user?: string,
55
+ * distro?: string,
56
+ * startCommand?: string,
57
+ * command?: string,
58
+ * }} [options]
59
+ * @returns {{
60
+ * kind: string,
61
+ * reason: string,
62
+ * notes: string[],
63
+ * commands: string[],
64
+ * options: Array<{ id: string, label: string, command: string, cost: string, recommended: boolean }>,
65
+ * canInstall: boolean,
66
+ * }}
67
+ */
68
+ export function planAutostartAdvice(options = {}) {
69
+ const host = options.host && typeof options.host === 'object' ? options.host : {}
70
+ const platform = text(options.platform) || text(host.platform) || process.platform
71
+ const kind = text(host.kind)
72
+ const suspect = host.suspect === true
73
+ const user = text(options.user) || text(process.env.USER) || text(process.env.LOGNAME) || '你的用户名'
74
+ const distro = text(options.distro) || text(host.distro) || '你的发行版'
75
+ const startCommand = text(options.startCommand)
76
+ const command = text(options.command)
77
+ const hasSystemd = options.hasSystemd === true
78
+ const isContainer = options.container === true
79
+
80
+ // A host that is only GUESSED to be WSL (the mounted Windows drive is the
81
+ // only evidence) gets no automatic anything: the plugin says so and leaves
82
+ // the choice to the user.
83
+ const suspectNote = suspect
84
+ ? '没把握:这台机器只是「挂载了 Windows 盘」才被猜成 WSL,插件不会替你改任何系统配置,建议你手动选一条。'
85
+ : ''
86
+
87
+ const linger = 'loginctl enable-linger ' + user
88
+
89
+ // (1) A CONTAINER. The orchestrator brings it back — there is no "at login".
90
+ if (isContainer) {
91
+ return {
92
+ kind: AUTOSTART_ADVICE_KINDS.CONTAINER,
93
+ reason: '容器里没有「开机自启」这回事:由编排器负责(compose 的 restart: always / k8s 的 restartPolicy)。',
94
+ notes: [
95
+ '容器里没有「开机自启」这回事:由编排器负责(compose 的 restart: always / k8s 的 restartPolicy)。',
96
+ '插件不会在容器里装 systemd —— 容器里也没有 systemd 可装。',
97
+ ],
98
+ commands: [],
99
+ options: [],
100
+ canInstall: false,
101
+ }
102
+ }
103
+
104
+ // (2) WINDOWS. The scheduled task first, the startup folder as the fallback.
105
+ if (kind === HOST_KINDS.WINDOWS || platform === 'win32') {
106
+ return {
107
+ kind: AUTOSTART_ADVICE_KINDS.WINDOWS,
108
+ reason: 'Windows 托管端:登录时自启用**计划任务**(/SC ONLOGON /RL LIMITED,不需要管理员);这台机器若不允许普通用户建计划任务,会自动降级到「启动文件夹」里的一个 .cmd(同样不需要管理员)。注册的命令是**真正的 node + supervisor.js**,不是 DSH 客户端本体。',
109
+ notes: [
110
+ '登录时自启:计划任务(/SC ONLOGON /RL LIMITED,零管理员权限)。',
111
+ '这台机器拒绝计划任务时自动降级为「启动文件夹」里的 .cmd(同样零权限)。',
112
+ '注册的命令是真正的 node + supervisor.js —— 绝不会用 DSH 客户端本体去跑守护脚本(那会在每次登录时再开一个 DSH)。',
113
+ ],
114
+ commands: command === '' ? [] : [command],
115
+ options: [],
116
+ canInstall: true,
117
+ }
118
+ }
119
+
120
+ // (3) WSL. With systemd it is a user unit (+ the linger); without it there
121
+ // is no "at login" at all — and NEVER a Docker answer.
122
+ if (kind === HOST_KINDS.WSL) {
123
+ if (hasSystemd) {
124
+ return {
125
+ kind: AUTOSTART_ADVICE_KINDS.WSL_SYSTEMD,
126
+ reason: 'WSL 里有 systemd:守护装成 systemd 用户单元(登录后自启)。'
127
+ + '若要「开机自启」(不必先登录),在 WSL 里运行一次:' + linger
128
+ + (suspectNote !== '' ? suspectNote : ''),
129
+ notes: [
130
+ 'WSL 里有 systemd:守护装成 systemd 用户单元(登录后自启)。',
131
+ '若要「开机自启」(不必先登录),在 WSL 里运行一次:' + linger
132
+ + '(只需一次;linger 让 systemd 在你没登录时也把这个用户的服务拉起来)。',
133
+ ],
134
+ commands: [linger],
135
+ options: [],
136
+ canInstall: true,
137
+ }
138
+ }
139
+ const systemdOption = {
140
+ id: 'wsl-systemd-enable',
141
+ label: '① 在 WSL 内部开启 systemd',
142
+ command: '在 /etc/wsl.conf 里加上 [boot] systemd=true,然后在 Windows 侧运行 wsl --shutdown 重启子系统',
143
+ cost: '这是系统配置改动:要改发行版里的 /etc/wsl.conf,并且要重启整个 WSL 子系统(别的发行版和正在跑的进程会一起停)。',
144
+ recommended: false,
145
+ }
146
+ const windowsOption = {
147
+ id: 'wsl-windows-task',
148
+ label: '② 在 Windows 侧用计划任务 / 启动文件夹拉起 WSL',
149
+ command: 'wsl.exe -d ' + distro + ' -e ' + (startCommand !== '' ? startCommand : '<启动命令>'),
150
+ cost: '需要 Windows 侧操作(新建一条计划任务,或往启动文件夹放一个快捷方式),但更接近真正的开机自启。',
151
+ recommended: true,
152
+ }
153
+ return {
154
+ kind: AUTOSTART_ADVICE_KINDS.WSL_MANUAL,
155
+ reason: 'WSL 里没有 systemd:这里没有「登录时自启」这回事。'
156
+ + '两条路(插件不会替你改任何系统配置):① 在 WSL 内部开启 systemd;② 在 Windows 侧用计划任务/启动文件夹拉起 WSL(推荐)。'
157
+ + (suspectNote !== '' ? suspectNote : ''),
158
+ notes: [
159
+ 'WSL 里没有 systemd:这里没有「登录时自启」这回事,所以插件不装任何东西。',
160
+ suspectNote !== '' ? suspectNote : '插件不会替你改任何系统配置,两条路都需要你自己动手。',
161
+ '① 在 WSL 内部开启 systemd —— 这是系统配置改动(改 /etc/wsl.conf + 重启子系统)。',
162
+ '② 在 Windows 侧用计划任务 / 启动文件夹拉起 WSL(推荐)—— 需要 Windows 侧操作,但更接近真正的开机自启。',
163
+ ],
164
+ commands: [windowsOption.command],
165
+ options: [systemdOption, windowsOption],
166
+ canInstall: false,
167
+ }
168
+ }
169
+
170
+ // (4) LINUX (and every other real Linux): a systemd user service, and the
171
+ // linger that turns "after a login" into "at boot".
172
+ if (kind === HOST_KINDS.LINUX || platform === 'linux') {
173
+ return {
174
+ kind: AUTOSTART_ADVICE_KINDS.LINUX,
175
+ reason: 'Linux 托管端:用 systemd 用户服务(systemctl --user,不需要管理员)。这是「登录后自启」;若要「开机自启」(不必先登录),运行一次 ' + linger + '。',
176
+ notes: [
177
+ 'systemd 用户服务:登录后自启(零管理员权限)。',
178
+ '若要「开机自启」(不必先登录),运行一次:' + linger + '(只需一次;没有 linger 就只是登录后自启)。',
179
+ ],
180
+ commands: [linger],
181
+ options: [],
182
+ canInstall: true,
183
+ }
184
+ }
185
+
186
+ // (5) UNKNOWN. Nothing is installed, nothing is guessed.
187
+ return {
188
+ kind: AUTOSTART_ADVICE_KINDS.UNKNOWN,
189
+ reason: '认不出这个宿主是怎么被拉起来的:插件不装任何东西,也不猜命令。请你手动选一条(或告诉插件该怎么拉起它)。',
190
+ notes: [
191
+ '认不出这个宿主是怎么被拉起来的:插件不装任何东西,也不猜命令。',
192
+ '这是「宁可说不知道,也不双开」的保守做法 —— 请你手动选一条。',
193
+ ],
194
+ commands: [],
195
+ options: [],
196
+ canInstall: false,
197
+ }
198
+ }