@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.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.
Files changed (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +83 -105
  3. package/README.zh.md +83 -105
  4. package/lib/client.js +902 -633
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2274 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +29 -1
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/host-api.d.ts +18 -0
  11. package/lib/types/client/locales.d.ts +10 -0
  12. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  13. package/lib/types/client/sidebar-entry.d.ts +8 -10
  14. package/lib/types/core/controller.d.ts +47 -15
  15. package/lib/types/core/schedule.d.ts +11 -2
  16. package/lib/types/core/tasks.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  18. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  19. package/lib/types/dsh-home.d.ts +16 -0
  20. package/lib/types/host-ledger.d.ts +52 -0
  21. package/lib/types/host-routes.d.ts +26 -0
  22. package/lib/types/host-runner.d.ts +41 -0
  23. package/lib/types/host-service.d.ts +45 -0
  24. package/lib/types/index.d.ts +17 -9
  25. package/lib/types/loopback.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +78 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +229 -18
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +24 -7
  33. package/src/client/board/TaskCard.tsx +25 -8
  34. package/src/client/board/TaskDetail.tsx +83 -50
  35. package/src/client/board-mount.tsx +1 -0
  36. package/src/client/host-api.ts +104 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/client/settings-card.module.css +90 -1
  40. package/src/client/sidebar-entry-core.ts +208 -0
  41. package/src/client/sidebar-entry.ts +24 -144
  42. package/src/core/controller.ts +222 -24
  43. package/src/core/schedule.ts +77 -13
  44. package/src/core/store.ts +4 -8
  45. package/src/core/tasks.ts +6 -6
  46. package/src/core/use-cases/task-archive.ts +14 -5
  47. package/src/core/use-cases/task-schedule.ts +6 -5
  48. package/src/dsh-home.ts +36 -0
  49. package/src/host-ledger.ts +539 -0
  50. package/src/host-routes.ts +196 -0
  51. package/src/host-runner.ts +152 -0
  52. package/src/host-service.ts +226 -0
  53. package/src/index.ts +54 -11
  54. package/src/loopback.ts +63 -0
  55. package/src/power-inhibitor.ts +321 -0
  56. package/src/protocol.ts +220 -0
  57. package/lib/types/core/scheduler.d.ts +0 -70
  58. package/src/core/scheduler.ts +0 -161
package/src/index.ts CHANGED
@@ -1,29 +1,31 @@
1
1
  /**
2
2
  * Host loader entry for the task-board plugin.
3
3
  *
4
- * Everything the board does is browser work (DOM, localStorage, driving the
5
- * client runtime's session services over the wire), so the host half's main
6
- * behavior is a system-prompt section announcing the plugin to every agent.
7
- * The section registers while this plugin is in the host composition (mount /
8
- * DSH restart) and disappears when the plugin leaves it (unmount / restart),
9
- * so agents always know the board exists and how to cooperate with it. The
10
- * announcement can be turned off through the web settings plugin-configuration
11
- * surface (`announceToAgent`); the section then disappears live.
4
+ * The Host owns the v2 ledger, action API, cron scheduler, session runner,
5
+ * execution reconciliation, and optional idle-sleep inhibitor. The browser is
6
+ * a same-origin asynchronous view over that service.
12
7
  */
13
8
 
14
9
  import type { Context } from '@deepseek-ai/cordis'
15
10
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
11
  import z from 'schemastery'
17
12
  import type {} from '@deepseek-ai/dsh-system-prompt'
13
+ import type {} from '@deepseek-ai/dsh-host-apiproxy'
14
+ import type {} from '@deepseek-ai/dsh-host-webserver'
15
+ import { TaskBoardHostService } from './host-service.ts'
16
+ import { makeTaskBoardRoutes } from './host-routes.ts'
18
17
  import { mountOnce } from './mount-once.ts'
19
18
 
20
19
  /** Order of the announcement section within the tool-guidance band. */
21
20
  const SECTION_ORDER = 200
22
21
 
23
- export const inject = ['systemPrompt']
22
+ /** Default environment variable holding the authenticated proxy token. */
23
+ export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
24
+
25
+ export const inject = ['systemPrompt', 'apiProxy', 'webServer']
24
26
 
25
27
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
26
- export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;任务可真实执行(驱动 agent 会话);任务可钉住执行目标——工作区 / 模式(agent 预设)/ 权限(read-only / workspace-write / danger-full-access),缺省用运行时默认;任务支持 5 段 cron 定时执行(如 0 23 * * *);数据存浏览器 localStorage(键 dsh.taskBoard.v1)。限制:定时调度在浏览器端,需 GUI 标签页打开,错过即跳过;执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
28
+ export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
27
29
 
28
30
  /**
29
31
  * Settings namespace of the board's announcement capability — the section the
@@ -42,13 +44,35 @@ export interface Config {
42
44
  announceToAgent?: boolean
43
45
  /** Master switch for the plugin (browser half + host announcement). */
44
46
  enabled?: boolean
47
+ /** Prevent idle system sleep while sessions run or schedules are armed. */
48
+ preventIdleSleep?: boolean
49
+ /** Canonical reverse-proxy Host authorities admitted with a server-side token. */
50
+ trustedProxyHosts?: string[]
51
+ /** Environment variable whose value the authenticated proxy injects upstream. */
52
+ proxyTokenEnv?: string
45
53
  }
46
54
 
47
55
  export const Config: z<Config> = z.object({
48
56
  announceToAgent: z.boolean().default(true),
49
57
  enabled: z.boolean().default(true),
58
+ preventIdleSleep: z.boolean().default(false),
59
+ trustedProxyHosts: z.array(z.string()).default([]),
60
+ proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
50
61
  })
51
62
 
63
+ /** Resolve proxy access without ever placing the token value in plugin config. */
64
+ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.ProcessEnv = process.env): { trustedProxyHosts: string[]; proxyToken?: string } {
65
+ const trustedProxyHosts = config?.trustedProxyHosts ?? []
66
+ if (trustedProxyHosts.length === 0) return { trustedProxyHosts }
67
+ const proxyTokenEnv = config?.proxyTokenEnv ?? DEFAULT_PROXY_TOKEN_ENV
68
+ if (proxyTokenEnv.trim() === '') throw new Error('task-board: proxyTokenEnv must not be empty')
69
+ const proxyToken = env[proxyTokenEnv]
70
+ if (proxyToken === undefined || proxyToken === '') {
71
+ throw new Error(`task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`)
72
+ }
73
+ return { trustedProxyHosts, proxyToken }
74
+ }
75
+
52
76
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
53
77
  const DEFAULT_ANNOUNCE = true
54
78
 
@@ -63,6 +87,23 @@ const DEFAULT_ANNOUNCE = true
63
87
  export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
64
88
 
65
89
  function applyImpl(ctx: Context, config?: Config): void {
90
+ const host = new TaskBoardHostService(ctx.apiProxy)
91
+ host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false)
92
+ host.start()
93
+ ctx.effect(() => {
94
+ const disposers: Array<() => void> = []
95
+ try {
96
+ for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route))
97
+ } catch (error) {
98
+ for (const dispose of disposers) dispose()
99
+ host.dispose()
100
+ throw error
101
+ }
102
+ return () => {
103
+ for (const dispose of disposers) dispose()
104
+ host.dispose()
105
+ }
106
+ }, 'task-board: host ledger, scheduler, and routes')
66
107
  // The live source the announcement reads: the settings section once the web
67
108
  // settings surface is served, the composition entry otherwise
68
109
  // (installSettingsSection swaps it when the namespace registers).
@@ -77,7 +118,9 @@ function applyImpl(ctx: Context, config?: Config): void {
77
118
  disposeSection()
78
119
  disposeSection = undefined
79
120
  }
80
- if ((current().enabled ?? true) === false) return
121
+ const active = current().enabled ?? true
122
+ host.setConfiguration(active, current().preventIdleSleep ?? false)
123
+ if (!active) return
81
124
  if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return
82
125
  disposeSection = ctx.systemPrompt.section({
83
126
  name: 'plugin:task-board',
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,321 @@
1
+ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, type SpawnSyncReturns } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { win32 } from 'node:path'
4
+ import type { PowerPhase, TaskBoardPowerSnapshot } from './protocol.ts'
5
+
6
+ const RETRY_DELAYS = [1_000, 2_000, 5_000, 10_000, 30_000] as const
7
+ const DARWIN_STABLE_MS = 30_000
8
+
9
+ const WINDOWS_HELPER = String.raw`
10
+ $source = @'
11
+ using System;
12
+ using System.Runtime.InteropServices;
13
+ public static class DshExecutionState {
14
+ [DllImport("kernel32.dll", SetLastError = true)]
15
+ public static extern uint SetThreadExecutionState(uint flags);
16
+ }
17
+ '@
18
+ Add-Type -TypeDefinition $source
19
+ $continuous = [Convert]::ToUInt32('80000000', 16)
20
+ $systemRequired = [uint32]0x00000001
21
+ try {
22
+ $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)
23
+ if ($result -eq 0) { throw 'SetThreadExecutionState failed' }
24
+ [Console]::Out.WriteLine('READY')
25
+ [Console]::Out.Flush()
26
+ while ([Console]::In.ReadLine() -ne $null) { }
27
+ } finally {
28
+ [void][DshExecutionState]::SetThreadExecutionState($continuous)
29
+ }
30
+ `
31
+
32
+ const LINUX_HELPER = String.raw`
33
+ process.stdout.write('READY\n')
34
+ process.stdin.resume()
35
+ `
36
+
37
+ const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit'] as const
38
+
39
+ export interface PowerReasons {
40
+ runningSessions: number
41
+ armedSchedules: number
42
+ sessionStateKnown: boolean
43
+ }
44
+
45
+ export interface SpawnOptions {
46
+ shell: false
47
+ windowsHide: boolean
48
+ stdio: ['pipe', 'pipe', 'pipe'] | ['ignore', 'ignore', 'ignore']
49
+ }
50
+
51
+ export type SpawnLike = (file: string, args: readonly string[], options: SpawnOptions) => ChildProcess
52
+ export type SpawnSyncLike = (
53
+ file: string,
54
+ args: readonly string[],
55
+ options: { stdio: 'ignore'; timeout: number; windowsHide: boolean },
56
+ ) => Pick<SpawnSyncReturns<Buffer>, 'status' | 'error'>
57
+
58
+ export interface PowerInhibitorOptions {
59
+ platform?: NodeJS.Platform
60
+ pid?: number
61
+ env?: NodeJS.ProcessEnv
62
+ spawn?: SpawnLike
63
+ spawnSync?: SpawnSyncLike
64
+ exists?: typeof existsSync
65
+ execPath?: string
66
+ setTimeout?: typeof globalThis.setTimeout
67
+ clearTimeout?: typeof globalThis.clearTimeout
68
+ }
69
+
70
+ export class PowerInhibitor {
71
+ private readonly listeners = new Set<() => void>()
72
+ private enabled = false
73
+ private reasons: PowerReasons = { runningSessions: 0, armedSchedules: 0, sessionStateKnown: false }
74
+ private phase: PowerPhase = 'disabled'
75
+ private child: ChildProcess | undefined
76
+ private retry: ReturnType<typeof setTimeout> | undefined
77
+ private retryReset: ReturnType<typeof setTimeout> | undefined
78
+ private retryIndex = 0
79
+ private lastError: string | undefined
80
+ private stopping = false
81
+ private readonly platform: NodeJS.Platform
82
+ private readonly pid: number
83
+ private readonly env: NodeJS.ProcessEnv
84
+ private readonly spawn: SpawnLike
85
+ private readonly spawnSync: SpawnSyncLike
86
+ private readonly exists: typeof existsSync
87
+ private readonly execPath: string
88
+ private readonly timer: typeof globalThis.setTimeout
89
+ private readonly clearTimer: typeof globalThis.clearTimeout
90
+ private linuxProbe: { executable?: string; available: boolean } | undefined
91
+
92
+ constructor(options: PowerInhibitorOptions = {}) {
93
+ this.platform = options.platform ?? process.platform
94
+ this.pid = options.pid ?? process.pid
95
+ this.env = options.env ?? process.env
96
+ this.spawn = options.spawn ?? ((file, args, spawnOptions) => nodeSpawn(file, [...args], spawnOptions))
97
+ this.spawnSync = options.spawnSync ?? ((file, args, spawnOptions) => nodeSpawnSync(file, [...args], spawnOptions))
98
+ this.exists = options.exists ?? existsSync
99
+ this.execPath = options.execPath ?? process.execPath
100
+ this.timer = options.setTimeout ?? globalThis.setTimeout
101
+ this.clearTimer = options.clearTimeout ?? globalThis.clearTimeout
102
+ }
103
+
104
+ setEnabled(enabled: boolean): void {
105
+ this.enabled = enabled
106
+ this.sync()
107
+ this.emit()
108
+ }
109
+
110
+ updateReasons(reasons: PowerReasons): void {
111
+ this.reasons = reasons
112
+ this.sync()
113
+ this.emit()
114
+ }
115
+
116
+ subscribe(listener: () => void): () => void {
117
+ this.listeners.add(listener)
118
+ return () => { this.listeners.delete(listener) }
119
+ }
120
+
121
+ snapshot(): TaskBoardPowerSnapshot {
122
+ return {
123
+ platform: this.platform,
124
+ phase: this.phase,
125
+ enabled: this.enabled,
126
+ ...this.reasons,
127
+ ...(this.lastError === undefined ? {} : { lastError: this.lastError }),
128
+ }
129
+ }
130
+
131
+ dispose(): void {
132
+ this.enabled = false
133
+ this.release()
134
+ this.phase = 'disabled'
135
+ this.emit()
136
+ this.listeners.clear()
137
+ }
138
+
139
+ private desired(): boolean {
140
+ return this.enabled && (!this.reasons.sessionStateKnown || this.reasons.runningSessions > 0 || this.reasons.armedSchedules > 0)
141
+ }
142
+
143
+ private sync(): void {
144
+ if (!this.enabled) {
145
+ this.release()
146
+ this.phase = 'disabled'
147
+ return
148
+ }
149
+ if (this.platform !== 'darwin' && this.platform !== 'win32' && this.platform !== 'linux') {
150
+ this.release()
151
+ this.phase = 'unsupported'
152
+ return
153
+ }
154
+ if (this.platform === 'linux' && this.linuxSystemdInhibit() === undefined) {
155
+ this.release()
156
+ this.phase = 'unsupported'
157
+ return
158
+ }
159
+ if (!this.desired()) {
160
+ this.release()
161
+ this.phase = 'idle'
162
+ return
163
+ }
164
+ if (this.child === undefined && this.retry === undefined) this.acquire()
165
+ }
166
+
167
+ private acquire(): void {
168
+ this.phase = 'acquiring'
169
+ this.emit()
170
+ this.stopping = false
171
+ try {
172
+ const child = this.spawnCommand()
173
+ this.child = child
174
+ let ready = false
175
+ let stderr = ''
176
+ if (this.platform === 'darwin') {
177
+ child.once('spawn', () => {
178
+ ready = true
179
+ this.markReady(child)
180
+ })
181
+ }
182
+ child.stdout?.on('data', (chunk: Buffer) => {
183
+ if (!ready && chunk.toString('utf8').includes('READY')) {
184
+ ready = true
185
+ this.markReady(child)
186
+ }
187
+ })
188
+ child.stderr?.on('data', (chunk: Buffer) => {
189
+ stderr = `${stderr}${chunk.toString('utf8')}`.slice(-2_000)
190
+ })
191
+ child.on('error', error => { this.fail(error, child) })
192
+ child.on('exit', (code, signal) => {
193
+ if (this.child !== child) return
194
+ this.child = undefined
195
+ if (this.stopping || !this.desired()) return
196
+ const detail = stderr.trim()
197
+ this.fail(new Error(`power helper exited (${String(code ?? signal ?? 'unknown')})${detail === '' ? '' : `: ${detail}`}`))
198
+ })
199
+ } catch (error) {
200
+ this.fail(error)
201
+ }
202
+ }
203
+
204
+ private markReady(child: ChildProcess): void {
205
+ if (this.child !== child || !this.desired()) return
206
+ this.phase = 'active'
207
+ if (this.platform === 'darwin') {
208
+ this.retryReset = this.timer(() => {
209
+ this.retryReset = undefined
210
+ if (this.child === child && this.desired()) this.retryIndex = 0
211
+ }, DARWIN_STABLE_MS)
212
+ } else {
213
+ this.retryIndex = 0
214
+ }
215
+ this.lastError = undefined
216
+ this.emit()
217
+ }
218
+
219
+ private fail(error: unknown, source?: ChildProcess): void {
220
+ if (source !== undefined && this.child !== source) return
221
+ this.clearRetryReset()
222
+ this.lastError = error instanceof Error ? error.message : String(error)
223
+ this.phase = 'error'
224
+ this.emit()
225
+ const child = this.child
226
+ this.child = undefined
227
+ child?.stdin?.end()
228
+ child?.kill()
229
+ if (!this.desired() || this.retry !== undefined) return
230
+ const delay = RETRY_DELAYS[Math.min(this.retryIndex, RETRY_DELAYS.length - 1)]
231
+ this.retryIndex += 1
232
+ this.retry = this.timer(() => {
233
+ this.retry = undefined
234
+ if (this.desired()) this.acquire()
235
+ }, delay)
236
+ }
237
+
238
+ private release(): void {
239
+ this.clearRetryReset()
240
+ if (this.retry !== undefined) {
241
+ this.clearTimer(this.retry)
242
+ this.retry = undefined
243
+ }
244
+ this.lastError = undefined
245
+ const child = this.child
246
+ this.child = undefined
247
+ if (child === undefined) return
248
+ this.stopping = true
249
+ if (this.platform === 'win32' || this.platform === 'linux') {
250
+ child.stdin?.end()
251
+ const force = this.timer(() => { if (child.exitCode === null) child.kill() }, 1_000)
252
+ child.once('exit', () => { this.clearTimer(force) })
253
+ } else {
254
+ child.kill('SIGTERM')
255
+ }
256
+ }
257
+
258
+ private clearRetryReset(): void {
259
+ if (this.retryReset === undefined) return
260
+ this.clearTimer(this.retryReset)
261
+ this.retryReset = undefined
262
+ }
263
+
264
+ private windowsPowerShell(): string {
265
+ const root = this.env.SystemRoot
266
+ if (root === undefined || root.trim() === '') throw new Error('SystemRoot is unavailable')
267
+ if (!win32.isAbsolute(root)) throw new Error('SystemRoot is not an absolute path')
268
+ return win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
269
+ }
270
+
271
+ private linuxSystemdInhibit(): string | undefined {
272
+ if (this.linuxProbe !== undefined) return this.linuxProbe.available ? this.linuxProbe.executable : undefined
273
+ const executable = LINUX_INHIBIT_PATHS.find(path => this.exists(path))
274
+ if (executable === undefined) {
275
+ this.linuxProbe = { available: false }
276
+ return undefined
277
+ }
278
+ const probe = this.spawnSync(executable, ['--list', '--no-pager'], {
279
+ stdio: 'ignore',
280
+ timeout: 2_000,
281
+ windowsHide: true,
282
+ })
283
+ this.linuxProbe = { executable, available: probe.status === 0 && probe.error === undefined }
284
+ return this.linuxProbe.available ? executable : undefined
285
+ }
286
+
287
+ private spawnCommand(): ChildProcess {
288
+ if (this.platform === 'darwin') {
289
+ return this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
290
+ shell: false,
291
+ windowsHide: false,
292
+ stdio: ['ignore', 'ignore', 'ignore'],
293
+ })
294
+ }
295
+ if (this.platform === 'linux') {
296
+ const executable = this.linuxSystemdInhibit()
297
+ if (executable === undefined) throw new Error('systemd-inhibit is unavailable')
298
+ return this.spawn(executable, [
299
+ '--what=idle',
300
+ '--who=DeepSeek Harness task board',
301
+ '--why=DSH sessions are running or schedules are armed',
302
+ '--mode=block',
303
+ '--',
304
+ this.execPath,
305
+ '-e',
306
+ LINUX_HELPER,
307
+ ], { shell: false, windowsHide: false, stdio: ['pipe', 'pipe', 'pipe'] })
308
+ }
309
+ return this.spawn(this.windowsPowerShell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
310
+ shell: false,
311
+ windowsHide: true,
312
+ stdio: ['pipe', 'pipe', 'pipe'],
313
+ })
314
+ }
315
+
316
+ private emit(): void {
317
+ for (const listener of [...this.listeners]) listener()
318
+ }
319
+ }
320
+
321
+ export { LINUX_HELPER, LINUX_INHIBIT_PATHS, WINDOWS_HELPER }