@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.
- package/README.i18n.yaml +2 -2
- package/README.md +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
package/src/loopback.ts
ADDED
|
@@ -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 }
|