@goodandready/dsh-kanban 0.1.24 → 0.1.26

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/index.js CHANGED
@@ -23,12 +23,15 @@ import {
23
23
  buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
24
  taskBody,
25
25
  taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
26
+ confirmPermission,
26
27
  } from './routes.js'
27
28
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
28
29
  import {
29
30
  runTask, resolveModel, buildStartMessage,
30
31
  runBatch, queueTask, unqueueTask, liveSessions,
32
+ PERMISSION_RANKS,
31
33
  } from './launcher.js'
34
+ import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
32
35
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
33
36
  import { createGiteaClient } from './gitea.js'
34
37
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
@@ -37,6 +40,9 @@ import { handleSessionEvent } from './lifecycle.js'
37
40
  import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
38
41
  import { verifySignature, parseEvent } from './webhook.js'
39
42
  import { planOutbound, planAssign, createOutbox } from './outbound.js'
43
+ import { computeNextRun } from './cron.js'
44
+ import { PowerInhibitor } from './power.js'
45
+ import { formatProgressDump, parseProgressDump } from './progress-dump.js'
40
46
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
41
47
 
42
48
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
@@ -196,17 +202,140 @@ export function apply(ctx, config) {
196
202
  return () => { try { store.close() } catch { /* уже закрыта */ } }
197
203
  }, 'dsh-kanban: хранилище задач')
198
204
 
199
- // Признак ожидания по событиям сессии. Задача ищется по идентификатору
200
- // сессии; сессий не с доски большинство, и для них здесь ничего не
201
- // происходит — это штатный ход, а не пропуск.
205
+ // Кроссплатформенный ингибитор сна системы во время работы задач
206
+ const powerInhibitor = new PowerInhibitor({
207
+ platform: process.platform,
208
+ pid: process.pid,
209
+ })
210
+
211
+ const syncPower = () => {
212
+ try {
213
+ const cfg = getConfig()
214
+ powerInhibitor.setEnabled(cfg.preventIdleSleep === true)
215
+ if (store) {
216
+ const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
217
+ powerInhibitor.setReasons(activeCount)
218
+ }
219
+ } catch { /* игнорируем */ }
220
+ }
221
+ rearmers.add(syncPower)
222
+ ctx.effect(() => {
223
+ syncPower()
224
+ return () => { powerInhibitor.destroy() }
225
+ }, 'dsh-kanban: ингибитор сна')
226
+
227
+ // Инъекция секции доски в системный промпт агента
228
+ let disposePromptSection
229
+ const syncPromptSection = () => {
230
+ if (disposePromptSection) {
231
+ try { disposePromptSection() } catch {}
232
+ disposePromptSection = undefined
233
+ }
234
+ const cfg = getConfig()
235
+ const sp = ctx.get('systemPrompt')
236
+ if (cfg.announceToAgent !== false && sp && typeof sp.section === 'function') {
237
+ try {
238
+ disposePromptSection = sp.section({
239
+ name: 'plugin:dsh-kanban',
240
+ order: 200,
241
+ text: 'В системе развернута доска задач dsh-kanban. При работе над задачами вы можете перемещать карточки инструментом board_move и вести чек-лист плана через board_plan. При исчерпании контекста или завершении подэтапа сформируйте блок <<<PROGRESSDUMP\\nЦель: ...\\nПрогресс: ...\\nСледующие шаги: ...\\n>>>PROGRESSDUMP для передачи эстафеты следующей сессии.',
242
+ })
243
+ } catch (err) {
244
+ ctx.logger?.warn?.(`dsh-kanban: не удалось зарегистрировать секцию промпта: ${err?.message}`)
245
+ }
246
+ }
247
+ }
248
+ rearmers.add(syncPromptSection)
249
+ ctx.effect(() => {
250
+ syncPromptSection()
251
+ return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
252
+ }, 'dsh-kanban: секция системного промпта')
253
+
254
+ // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
202
255
  ctx.effect(() => ctx.on('session/event', (session, event) => {
203
256
  try {
204
- handleSessionEvent({ store, sessionId: String(session?.id ?? ''), type: event?.type })
257
+ const text = event?.content?.[0]?.text ?? event?.text ?? ''
258
+ handleSessionEvent({
259
+ store,
260
+ sessionId: String(session?.id ?? ''),
261
+ type: event?.type,
262
+ text,
263
+ error: event?.error,
264
+ })
265
+ syncPower()
205
266
  } catch (error) {
206
267
  ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
207
268
  }
208
269
  }), 'dsh-kanban: события сессии')
209
270
 
271
+ // Фоновый шедулер cron для задач
272
+ ctx.effect(() => {
273
+ let cronTimer
274
+ const armCron = () => {
275
+ clearInterval(cronTimer)
276
+ cronTimer = undefined
277
+ if (getConfig().cronEnabled !== true) return
278
+ cronTimer = setInterval(async () => {
279
+ try {
280
+ if (!store) return
281
+ const now = Date.now()
282
+ const dueTasks = store.listDueTasks(now)
283
+ for (const task of dueTasks) {
284
+ if (task.column === 'in-progress') {
285
+ const next = computeNextRun(task.cron, now)
286
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
287
+ continue
288
+ }
289
+ if (getConfig().permissionGateEnabled) {
290
+ const perm = task.permission || 'read-only'
291
+ const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
292
+ if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
293
+ const next = computeNextRun(task.cron, now)
294
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
295
+ store.addTransition({
296
+ taskId: task.id,
297
+ fromCol: task.column,
298
+ toCol: task.column,
299
+ source: 'tool',
300
+ detail: 'cron: запуск отклонён (требуется подтверждение повышенных прав)',
301
+ })
302
+ continue
303
+ }
304
+ }
305
+ const picked = resolveModel({
306
+ requested: { model: task.model, provider: task.provider },
307
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
308
+ })
309
+ if (picked.model) {
310
+ await runTask({
311
+ agents: ctx.agents,
312
+ store,
313
+ task,
314
+ config: getConfig(),
315
+ provider: picked.provider,
316
+ model: picked.model,
317
+ mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
318
+ createMessage: createUserMessage,
319
+ logger: ctx.logger,
320
+ workspaceRegistry: ctx.get('workspaceRegistry'),
321
+ claimAs: 'cron',
322
+ })
323
+ syncPower()
324
+ }
325
+ const next = computeNextRun(task.cron, now)
326
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
327
+ }
328
+ } catch (err) {
329
+ ctx.logger?.warn?.(`dsh-kanban: ошибка шедулера cron: ${err?.message}`)
330
+ }
331
+ }, 30_000)
332
+ if (typeof cronTimer.unref === 'function') cronTimer.unref()
333
+ }
334
+ armCron()
335
+ rearmers.add(armCron)
336
+ return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
337
+ }, 'dsh-kanban: шедулер cron')
338
+
210
339
  // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
211
340
  // перекрытие запусков не допускается: медленный инстанс иначе копил бы
212
341
  // очередь сверок вместо одной идущей.
@@ -645,7 +774,7 @@ export function apply(ctx, config) {
645
774
  fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
646
775
  })
647
776
  if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
648
- reply(res, await runTask({
777
+ const started = await runTask({
649
778
  agents: ctx.agents,
650
779
  store,
651
780
  task,
@@ -661,8 +790,11 @@ export function apply(ctx, config) {
661
790
  agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
662
791
  permission: typeof input.permission === 'string' ? input.permission : undefined,
663
792
  permissions: ctx.get('permissionPresets'),
793
+ workspaceRegistry: ctx.get('workspaceRegistry'),
664
794
  claimAs: await whoAmI(),
665
- }))
795
+ })
796
+ syncPower()
797
+ reply(res, started)
666
798
  } catch (error) {
667
799
  ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
668
800
  // Отказ по уровню доступа называется своим именем: «не удалось» на
@@ -698,15 +830,64 @@ export function apply(ctx, config) {
698
830
  if (out?.task !== undefined) {
699
831
  queueOutbound(id, out.task.column)
700
832
  if (acted !== undefined) out.command = acted
833
+ if (input.column === 'cleanup' && was?.worktree) {
834
+ const dirty = await checkWorktreeDirty({ worktreePath: was.worktree })
835
+ if (dirty.dirty) {
836
+ out.worktreeWarning = 'worktree-dirty'
837
+ out.dirtyFiles = dirty.files
838
+ }
839
+ }
701
840
  }
702
841
  reply(res, out)
703
842
  return
704
843
  }
844
+ if (req.method === 'POST' && action === 'cleanup-worktree') {
845
+ const task = store.getTask(id)
846
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
847
+ const out = await cleanupTaskWorktree({
848
+ store,
849
+ task,
850
+ workspaceRegistry: ctx.get('workspaceRegistry'),
851
+ force: input?.force === true,
852
+ })
853
+ reply(res, out)
854
+ return
855
+ }
856
+ if (req.method === 'GET' && action === 'worktree-status') {
857
+ const task = store.getTask(id)
858
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
859
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
860
+ reply(res, {
861
+ worktree: task.worktree || '',
862
+ branch: task.branch || '',
863
+ workspaceId: task.workspaceId || '',
864
+ dirty: dirtyCheck.dirty,
865
+ files: dirtyCheck.files,
866
+ })
867
+ return
868
+ }
869
+ if (req.method === 'POST' && action === 'confirm-permission') {
870
+ reply(res, confirmPermission({ store, id }))
871
+ return
872
+ }
873
+ if (req.method === 'POST' && action === 'progress-dump') {
874
+ const task = store.getTask(id)
875
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
876
+ const formatted = typeof input === 'string' ? input : formatProgressDump(input)
877
+ const updated = store.updateTask(id, { progressDump: formatted })
878
+ reply(res, { task: updated })
879
+ return
880
+ }
705
881
  if (req.method === 'POST' && action === 'stop') {
706
882
  const task = store.getTask(id)
707
883
  if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
708
884
  const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
709
885
  if (out.acted === 'stopped') {
886
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
887
+ if (latestRun && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
888
+ store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
889
+ }
890
+ syncPower()
710
891
  // Остановка — событие, и в журнале ей место наравне с переносом:
711
892
  // иначе прерванная работа выглядит просто оборвавшейся сама.
712
893
  store.addTransition({
package/lib/launcher.js CHANGED
@@ -13,6 +13,11 @@
13
13
  // приходят параметрами. Так модуль проверяется без харнесса и без сети.
14
14
 
15
15
  import { isAbsolute, join, resolve, sep } from 'node:path'
16
+ import { randomUUID } from 'node:crypto'
17
+ import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
18
+ import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
19
+
20
+ export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
16
21
 
17
22
  /**
18
23
  * Хвост своей задачи — УСЛОВИЕ, а не приказ.
@@ -146,7 +151,7 @@ export function applyPermission({ permissions, agent, name }) {
146
151
 
147
152
  export async function obtainAgent({
148
153
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
149
- agentPreset, permission, permissions,
154
+ agentPreset, permission, permissions, gitRunner, workspaceRegistry,
150
155
  }) {
151
156
  const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
152
157
 
@@ -165,12 +170,51 @@ export async function obtainAgent({
165
170
  }
166
171
  }
167
172
 
168
- const cwd = resolveCwd(task, config, cwdOf)
173
+ let cwd = resolveCwd(task, config, cwdOf)
174
+ let worktreePath = task?.worktree || undefined
175
+ let branchName = task?.branch || undefined
176
+ let workspaceId = task?.workspaceId || undefined
177
+ let worktreeCreated = false
178
+
179
+ if (config?.worktreeIsolation !== false && task?.repo) {
180
+ try {
181
+ const created = await createTaskWorktree({
182
+ task,
183
+ repoDir: cwd,
184
+ config,
185
+ gitRunner,
186
+ cwdOf,
187
+ })
188
+ if (created) {
189
+ worktreePath = created.worktreePath
190
+ branchName = created.branchName
191
+ worktreeCreated = created.created === true
192
+ cwd = created.worktreePath
193
+
194
+ if (workspaceRegistry) {
195
+ workspaceId = await registerDshWorkspace({
196
+ workspaceRegistry,
197
+ worktreePath,
198
+ title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
199
+ })
200
+ }
201
+ }
202
+ } catch (err) {
203
+ logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
204
+ }
205
+ } else if (worktreePath) {
206
+ cwd = worktreePath
207
+ }
208
+
209
+ const meta = { cwd }
210
+ if (typeof agentPreset === 'string' && agentPreset !== '') meta.agentPreset = agentPreset
211
+ if (workspaceId) meta.workspaceId = workspaceId
212
+
169
213
  const handle = await agents.create({
170
214
  sessionId: mintSessionId(),
171
215
  // Профиль — факт создания сессии: у идущей его не сменить, поэтому он
172
216
  // едет здесь, а не отдельным вызовом после.
173
- meta: typeof agentPreset === 'string' && agentPreset !== '' ? { cwd, agentPreset } : { cwd },
217
+ meta,
174
218
  agentOptions: { provider, model },
175
219
  })
176
220
  const access = applyPermission({ permissions, agent: handle.agent, name: permission })
@@ -183,7 +227,15 @@ export async function obtainAgent({
183
227
  error.status = 409
184
228
  throw error
185
229
  }
186
- return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'created' }
230
+ return {
231
+ agent: handle.agent,
232
+ sessionId: handle.agent.session.id,
233
+ mode: 'created',
234
+ worktree: worktreePath,
235
+ branch: branchName,
236
+ workspaceId,
237
+ worktreeCreated,
238
+ }
187
239
  }
188
240
 
189
241
  /**
@@ -241,12 +293,37 @@ export async function runTask({
241
293
  agents, store, task, config, provider, model,
242
294
  mintSessionId, createMessage, cwdOf, logger, text,
243
295
  agentPreset, permission, permissions, claimAs,
296
+ gitRunner, workspaceRegistry,
244
297
  }) {
298
+ if (config?.permissionGateEnabled) {
299
+ const perm = permission || task.permission || 'read-only'
300
+ const defaultPerm = config?.sessionDefaultPermission || 'read-only'
301
+ if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
302
+ if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
303
+ const err = new Error('Требуется подтверждение прав')
304
+ err.key = 'permission-unconfirmed'
305
+ err.status = 403
306
+ throw err
307
+ }
308
+ }
309
+ }
310
+
245
311
  const taken = await obtainAgent({
246
312
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
247
- agentPreset, permission, permissions,
313
+ agentPreset, permission, permissions, gitRunner, workspaceRegistry,
248
314
  })
249
315
 
316
+ // Фиксируем запуск в истории задачи
317
+ if (typeof store.recordTaskRun === 'function') {
318
+ store.recordTaskRun(task.id, {
319
+ id: randomUUID(),
320
+ sessionId: taken.sessionId,
321
+ startedAt: Date.now(),
322
+ model,
323
+ provider,
324
+ })
325
+ }
326
+
250
327
  await taken.agent.whenIdle()
251
328
  taken.agent.followup(createMessage({
252
329
  // Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
@@ -256,12 +333,31 @@ export async function runTask({
256
333
  }))
257
334
 
258
335
  const before = store.getTask(task.id)
259
- const patch = { sessionId: taken.sessionId, provider, model }
336
+ const patch = {
337
+ sessionId: taken.sessionId,
338
+ provider,
339
+ model,
340
+ ...(permission ? { permission } : {}),
341
+ ...(taken.worktree ? { worktree: taken.worktree } : {}),
342
+ ...(taken.branch ? { branch: taken.branch } : {}),
343
+ ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
344
+ }
260
345
  store.updateTask(task.id, patch)
261
346
  // Запустил — значит отвечаешь: свободная задача становится твоей, чужая
262
347
  // остаётся чужой.
263
348
  claimOnStart({ store, task: before, login: claimAs })
264
349
 
350
+ // Изоляция сессии в отдельном воркдереве фиксируется в журнале.
351
+ if (taken.worktreeCreated) {
352
+ store.addTransition({
353
+ taskId: task.id,
354
+ fromCol: before.column,
355
+ toCol: before.column,
356
+ source: 'session',
357
+ detail: `изоляция: создано воркдерево ${taken.worktree} (${taken.branch})`,
358
+ })
359
+ }
360
+
265
361
  // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
266
362
  // поймёт, куда делась переписка.
267
363
  if (taken.mode === 'created' && before.sessionId) {
@@ -292,7 +388,20 @@ export async function runTask({
292
388
  /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
293
389
  function messageFor(task, config, text) {
294
390
  const own = typeof text === 'string' ? text.trim() : ''
295
- return own !== '' ? own : buildStartMessage(task, config)
391
+ const base = own !== '' ? own : buildStartMessage(task, config)
392
+ if (config?.progressDumpEnabled && task.progressDump) {
393
+ let parsed
394
+ try {
395
+ parsed = typeof task.progressDump === 'string' && task.progressDump.startsWith('{')
396
+ ? JSON.parse(task.progressDump)
397
+ : parseProgressDump(task.progressDump)?.dump
398
+ } catch {
399
+ parsed = parseProgressDump(task.progressDump)?.dump
400
+ }
401
+ const preamble = formatHandoverPreamble(parsed)
402
+ if (preamble) return `${preamble}${base}`
403
+ }
404
+ return base
296
405
  }
297
406
 
298
407
  /**
package/lib/lifecycle.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // делать с очередным событием.
10
10
 
11
11
  import { waitingFromEvent } from './transitions.js'
12
+ import { parseProgressDump, formatProgressDump } from './progress-dump.js'
12
13
 
13
14
  /**
14
15
  * Обработать событие сессии.
@@ -17,14 +18,42 @@ import { waitingFromEvent } from './transitions.js'
17
18
  * большинство — для них здесь ничего не происходит, и это штатный ход, а не
18
19
  * пропуск.
19
20
  *
20
- * @returns {{taskId: string, waiting: boolean}|undefined} что изменилось
21
+ * @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
21
22
  */
22
- export function handleSessionEvent({ store, sessionId, type }) {
23
- const next = waitingFromEvent(type)
24
- if (next === undefined) return undefined
25
-
23
+ export function handleSessionEvent({ store, sessionId, type, text, error }) {
26
24
  const task = store.findTaskBySession(sessionId)
27
25
  if (task === undefined) return undefined
26
+
27
+ // Если в ответе пришёл PROGRESSDUMP — обновляем срез в задаче
28
+ if (typeof text === 'string' && text.includes('<<<PROGRESSDUMP')) {
29
+ const parsed = parseProgressDump(text)
30
+ if (parsed.ok) {
31
+ store.updateTask(task.id, { progressDump: formatProgressDump(parsed.dump) })
32
+ }
33
+ }
34
+
35
+ // Обновляем исход запуска в истории runs
36
+ if (type === 'turn/end' || type === 'turn/completed') {
37
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
38
+ if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
39
+ store.updateTaskRun(task.id, latestRun.id, {
40
+ endedAt: Date.now(),
41
+ result: 'succeeded',
42
+ })
43
+ }
44
+ } else if (type === 'turn/error' || type === 'session/error') {
45
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
46
+ if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
47
+ store.updateTaskRun(task.id, latestRun.id, {
48
+ endedAt: Date.now(),
49
+ result: 'failed',
50
+ error: typeof error === 'string' ? error : error?.message || 'error',
51
+ })
52
+ }
53
+ }
54
+
55
+ const next = waitingFromEvent(type)
56
+ if (next === undefined) return undefined
28
57
  if (task.waiting === next) return undefined
29
58
 
30
59
  store.updateTask(task.id, { waiting: next })
package/lib/power.js ADDED
@@ -0,0 +1,157 @@
1
+ // dsh-kanban: кроссплатформенный ингибитор сна системы во время работы задач.
2
+ // Поддерживает Windows (PowerShell + Win32 API), Linux (systemd-inhibit) и macOS (caffeinate).
3
+
4
+ import { spawn as defaultSpawn } from 'node:child_process'
5
+ import { existsSync } from 'node:fs'
6
+
7
+ const WINDOWS_HELPER = [
8
+ '$source = @\'',
9
+ 'using System;',
10
+ 'using System.Runtime.InteropServices;',
11
+ 'public static class DshExecutionState {',
12
+ ' [DllImport("kernel32.dll", SetLastError = true)]',
13
+ ' public static extern uint SetThreadExecutionState(uint flags);',
14
+ '}',
15
+ '\'@',
16
+ 'Add-Type -TypeDefinition $source',
17
+ "$continuous = [Convert]::ToUInt32('80000000', 16)",
18
+ '$systemRequired = [uint32]0x00000001',
19
+ 'try {',
20
+ ' $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)',
21
+ ' if ($result -eq 0) { throw \'SetThreadExecutionState failed\' }',
22
+ ' [Console]::Out.WriteLine(\'READY\')',
23
+ ' [Console]::Out.Flush()',
24
+ ' while ([Console]::In.ReadLine() -ne $null) { }',
25
+ '} finally {',
26
+ ' [void][DshExecutionState]::SetThreadExecutionState($continuous)',
27
+ '}',
28
+ ].join('\n')
29
+
30
+ const LINUX_HELPER = "process.stdout.write('READY\\n'); process.stdin.resume();"
31
+
32
+ const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit']
33
+
34
+ export class PowerInhibitor {
35
+ constructor(options = {}) {
36
+ this.platform = options.platform ?? process.platform
37
+ this.pid = options.pid ?? process.pid
38
+ this.spawn = options.spawn ?? defaultSpawn
39
+ this.exists = options.exists ?? existsSync
40
+ this.enabled = false
41
+ this.reasons = 0
42
+ this.child = null
43
+ this.phase = 'disabled'
44
+ this.lastError = null
45
+ }
46
+
47
+ setEnabled(enabled) {
48
+ this.enabled = Boolean(enabled)
49
+ this.sync()
50
+ }
51
+
52
+ setReasons(count) {
53
+ this.reasons = Math.max(0, Number(count) || 0)
54
+ this.sync()
55
+ }
56
+
57
+ acquire() {
58
+ this.reasons++
59
+ this.sync()
60
+ }
61
+
62
+ release() {
63
+ this.reasons = Math.max(0, this.reasons - 1)
64
+ this.sync()
65
+ }
66
+
67
+ sync() {
68
+ const shouldHold = this.enabled && this.reasons > 0
69
+ if (shouldHold && !this.child) {
70
+ this.startHelper()
71
+ } else if (!shouldHold && this.child) {
72
+ this.stopHelper()
73
+ }
74
+ }
75
+
76
+ startHelper() {
77
+ this.stopHelper()
78
+ try {
79
+ if (this.platform === 'win32') {
80
+ const psPath = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
81
+ this.child = this.spawn(psPath, ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
82
+ shell: false,
83
+ windowsHide: true,
84
+ stdio: ['pipe', 'pipe', 'pipe'],
85
+ })
86
+ } else if (this.platform === 'darwin') {
87
+ this.child = this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
88
+ shell: false,
89
+ windowsHide: true,
90
+ stdio: ['ignore', 'ignore', 'ignore'],
91
+ })
92
+ } else if (this.platform === 'linux') {
93
+ const bin = LINUX_INHIBIT_PATHS.find((p) => this.exists(p))
94
+ if (!bin) {
95
+ this.phase = 'unsupported'
96
+ return
97
+ }
98
+ this.child = this.spawn(
99
+ bin,
100
+ ['--what=idle', '--who=dsh-kanban', '--why=Task running', 'node', '-e', LINUX_HELPER],
101
+ { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
102
+ )
103
+ } else {
104
+ this.phase = 'unsupported'
105
+ return
106
+ }
107
+
108
+ this.phase = 'running'
109
+ this.lastError = null
110
+
111
+ this.child.on('error', (err) => {
112
+ this.lastError = err?.message
113
+ this.phase = 'error'
114
+ this.child = null
115
+ })
116
+
117
+ this.child.on('exit', () => {
118
+ this.child = null
119
+ if (this.enabled && this.reasons > 0) {
120
+ this.phase = 'disabled'
121
+ }
122
+ })
123
+ } catch (err) {
124
+ this.lastError = err?.message
125
+ this.phase = 'error'
126
+ this.child = null
127
+ }
128
+ }
129
+
130
+ stopHelper() {
131
+ if (this.child) {
132
+ try {
133
+ if (this.child.stdin && !this.child.stdin.destroyed) {
134
+ this.child.stdin.end()
135
+ }
136
+ this.child.kill()
137
+ } catch {}
138
+ this.child = null
139
+ }
140
+ this.phase = 'disabled'
141
+ }
142
+
143
+ snapshot() {
144
+ return {
145
+ enabled: this.enabled,
146
+ reasons: this.reasons,
147
+ phase: this.phase,
148
+ lastError: this.lastError,
149
+ }
150
+ }
151
+
152
+ destroy() {
153
+ this.enabled = false
154
+ this.reasons = 0
155
+ this.stopHelper()
156
+ }
157
+ }