@linxin666/dsh-client-ui-task-board 0.1.19 → 0.2.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.
Files changed (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +82 -104
  3. package/README.zh.md +82 -104
  4. package/lib/client.js +589 -602
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2187 -6
  7. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  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/board/status-key.d.ts +4 -0
  11. package/lib/types/client/host-api.d.ts +18 -0
  12. package/lib/types/client/locales.d.ts +10 -0
  13. package/lib/types/core/controller.d.ts +40 -15
  14. package/lib/types/core/schedule.d.ts +4 -2
  15. package/lib/types/core/tasks.d.ts +7 -5
  16. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  17. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  18. package/lib/types/dsh-home.d.ts +16 -0
  19. package/lib/types/host-ledger.d.ts +42 -0
  20. package/lib/types/host-routes.d.ts +24 -0
  21. package/lib/types/host-runner.d.ts +30 -0
  22. package/lib/types/host-service.d.ts +41 -0
  23. package/lib/types/index.d.ts +20 -10
  24. package/lib/types/loopback.d.ts +24 -0
  25. package/lib/types/mount-once.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +72 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +10 -0
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +22 -13
  33. package/src/client/board/TaskCard.tsx +24 -8
  34. package/src/client/board/TaskDetail.tsx +84 -59
  35. package/src/client/board/status-key.ts +11 -0
  36. package/src/client/host-api.ts +95 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/core/controller.ts +205 -24
  40. package/src/core/schedule.ts +20 -3
  41. package/src/core/store.ts +5 -14
  42. package/src/core/tasks.ts +7 -7
  43. package/src/core/use-cases/task-archive.ts +14 -5
  44. package/src/core/use-cases/task-schedule.ts +6 -5
  45. package/src/core/use-cases/task-update.ts +1 -6
  46. package/src/dsh-home.ts +36 -0
  47. package/src/host-ledger.ts +478 -0
  48. package/src/host-routes.ts +188 -0
  49. package/src/host-runner.ts +134 -0
  50. package/src/host-service.ts +209 -0
  51. package/src/index.ts +58 -12
  52. package/src/loopback.ts +63 -0
  53. package/src/mount-once.ts +48 -0
  54. package/src/power-inhibitor.ts +321 -0
  55. package/src/protocol.ts +213 -0
  56. package/lib/types/core/scheduler.d.ts +0 -67
  57. package/src/core/scheduler.ts +0 -151
@@ -12,7 +12,8 @@ import { t, type TaskBoardKey } from '../locales.ts'
12
12
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
13
  import css from '../board.module.css'
14
14
  import { ConfirmDialog } from './ConfirmDialog.tsx'
15
- import { formatTime } from './TaskCard.tsx'
15
+ import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
16
+ import { STATUS_KEY } from './status-key.ts'
16
17
 
17
18
  /** Execution outcome → locale key. */
18
19
  const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
@@ -21,17 +22,8 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
21
22
  cancelled: 'detail.result.cancelled',
22
23
  }
23
24
 
24
- /** Status → locale key (detail badge). */
25
- const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
26
- backlog: 'board.status.backlog',
27
- todo: 'board.status.todo',
28
- running: 'board.status.running',
29
- done: 'board.status.done',
30
- failed: 'board.status.failed',
31
- }
32
-
33
25
  /** One execution-history row. */
34
- function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
26
+ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
35
27
  const result = execution.result
36
28
  return (
37
29
  <li className={css.executionRow} data-result={result}>
@@ -39,8 +31,8 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
39
31
  {result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
40
32
  </span>
41
33
  <span className={css.executionTimes}>
42
- {t('detail.executionStarted')} {formatTime(execution.startedAt)}
43
- {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt)}`}
34
+ {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
35
+ {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
44
36
  </span>
45
37
  {execution.sessionId !== undefined && (
46
38
  <button
@@ -60,7 +52,7 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
60
52
  }
61
53
 
62
54
  /** The execution-target editor: workspace / mode / permission pickers. */
63
- function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
55
+ function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
64
56
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
65
57
  useEffect(
66
58
  () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
@@ -83,6 +75,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
83
75
  <select
84
76
  className={css.select}
85
77
  value={workspaceId}
78
+ disabled={pending}
86
79
  onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
87
80
  >
88
81
  <option value="">{t('exec.workspace.recent')}</option>
@@ -97,6 +90,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
97
90
  <select
98
91
  className={css.select}
99
92
  value={mode}
93
+ disabled={pending}
100
94
  onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
101
95
  >
102
96
  <option value="">{t('exec.mode.default')}</option>
@@ -115,6 +109,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
115
109
  <select
116
110
  className={css.select}
117
111
  value={permission}
112
+ disabled={pending}
118
113
  onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
119
114
  >
120
115
  <option value="">{t('exec.permission.default')}</option>
@@ -128,13 +123,14 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
128
123
  }
129
124
 
130
125
  /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
131
- function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
126
+ function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
132
127
  const schedule = task.schedule
133
128
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
134
129
  const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
135
130
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
136
131
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
137
132
  const [error, setError] = useState<string | undefined>(undefined)
133
+ const timeZone = controller.getSnapshot().host?.scheduler.timeZone
138
134
 
139
135
  // Keep the editor in sync when the task record changes underneath (the
140
136
  // schedule rolls forward as runs trigger).
@@ -166,8 +162,11 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
166
162
  return
167
163
  }
168
164
  setError(undefined)
169
- if (next && trimmed !== schedule?.cron) controller.setSchedule(task.id, { cron: trimmed })
170
- if (controller.setSchedule(task.id, { enabled: next })) setEnabled(next)
165
+ const submitted = controller.setSchedule(task.id, {
166
+ enabled: next,
167
+ ...(next && trimmed !== schedule?.cron ? { cron: trimmed } : {}),
168
+ })
169
+ if (submitted && !controller.isHostBacked()) setEnabled(next)
171
170
  }
172
171
 
173
172
  const applyPreset = (preset: string): void => {
@@ -181,8 +180,8 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
181
180
  ? t('detail.schedule.notScheduled')
182
181
  : nextRunAt <= Date.now()
183
182
  ? t('detail.schedule.dueSoon')
184
- : new Date(nextRunAt).toLocaleString()
185
- const lastLabel = lastTriggeredAt === undefined ? '—' : new Date(lastTriggeredAt).toLocaleString()
183
+ : formatHostTimestamp(nextRunAt, timeZone)
184
+ const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
186
185
 
187
186
  return (
188
187
  <section className={css.detailSection}>
@@ -191,6 +190,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
191
190
  <input
192
191
  type="checkbox"
193
192
  checked={enabled}
193
+ disabled={pending}
194
194
  onChange={event => { toggleEnabled(event.target.checked) }}
195
195
  />
196
196
  <span>{t('detail.schedule.enable')}</span>
@@ -199,6 +199,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
199
199
  <input
200
200
  className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
201
201
  value={cron}
202
+ disabled={pending}
202
203
  placeholder="0 9 * * *"
203
204
  spellCheck={false}
204
205
  aria-label={t('detail.schedule.cron')}
@@ -209,6 +210,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
209
210
  <select
210
211
  className={css.schedulePreset}
211
212
  value=""
213
+ disabled={pending}
212
214
  aria-label={t('detail.schedule.presets')}
213
215
  onChange={event => { applyPreset(event.target.value) }}
214
216
  >
@@ -230,19 +232,26 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
230
232
  /** Task detail overlay. */
231
233
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
232
234
  const [confirmDelete, setConfirmDelete] = useState(false)
233
- const running = task.status === 'running'
234
235
 
235
236
  // Keep the overlay in sync if the task record changes underneath.
236
237
  const [latest, setLatest] = useState(task)
237
238
  useEffect(() => { setLatest(task) }, [task])
238
239
  const current = latest
240
+ const snapshot = controller.getSnapshot()
241
+ const running = current.status === 'running'
242
+ const archived = current.archivedAt !== undefined
243
+ const pending = snapshot.pendingTaskIds.includes(current.id)
244
+ const transportError = snapshot.transportError
245
+ const timeZone = snapshot.host?.scheduler.timeZone
239
246
 
240
247
  return (
241
248
  <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
242
249
  <div className={css.detail} role="dialog" aria-label={t('detail.title')}>
243
250
  <header className={css.detailHeader}>
244
251
  <h2 className={css.detailTitle}>{current.title}</h2>
245
- <span className={css.statusBadge} data-status={current.status}>{t(STATUS_KEY[current.status])}</span>
252
+ <span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
253
+ {archived ? t('board.archive') : t(STATUS_KEY[current.status])}
254
+ </span>
246
255
  <button
247
256
  type="button"
248
257
  className={css.iconButton}
@@ -254,6 +263,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
254
263
  </header>
255
264
 
256
265
  <div className={css.detailBody}>
266
+ {transportError !== undefined && (
267
+ <div className={css.formError}>
268
+ {t('board.hostError', { error: transportError })}{' '}
269
+ <button type="button" className={css.linkButton} onClick={() => { void controller.retryHostSync() }}>
270
+ {t('board.retryHost')}
271
+ </button>
272
+ </div>
273
+ )}
257
274
  <section className={css.detailSection}>
258
275
  <h4>{t('detail.description')}</h4>
259
276
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
@@ -264,9 +281,12 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
264
281
  <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
265
282
  </section>
266
283
 
267
- <ExecutionSettingsSection controller={controller} task={current} />
268
-
269
- <ScheduleSection controller={controller} task={current} />
284
+ {!archived && (
285
+ <>
286
+ <ExecutionSettingsSection controller={controller} task={current} pending={pending} />
287
+ <ScheduleSection controller={controller} task={current} pending={pending} />
288
+ </>
289
+ )}
270
290
 
271
291
  <section className={css.detailSection}>
272
292
  <h4>{t('detail.execution')}</h4>
@@ -278,6 +298,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
278
298
  <ExecutionRow
279
299
  key={execution.id}
280
300
  execution={execution}
301
+ timeZone={timeZone}
281
302
  onOpen={sessionId => { controller.openSession(sessionId) }}
282
303
  />
283
304
  ))}
@@ -285,45 +306,49 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
285
306
  )}
286
307
  </section>
287
308
 
288
- <section className={css.detailSection}>
289
- <h4>{t('board.status')}</h4>
290
- <div className={css.moveRow}>
291
- {MANUAL_STATUSES.map(status => (
292
- <button
293
- key={status}
294
- type="button"
295
- className={css.ghostButton}
296
- disabled={current.status === status || running}
297
- onClick={() => { controller.moveTask(current.id, status) }}
298
- >
299
- {t(`status.move.${status}` as TaskBoardKey)}
300
- </button>
301
- ))}
302
- </div>
303
- </section>
309
+ {!archived && (
310
+ <section className={css.detailSection}>
311
+ <h4>{t('board.status')}</h4>
312
+ <div className={css.moveRow}>
313
+ {MANUAL_STATUSES.map(status => (
314
+ <button
315
+ key={status}
316
+ type="button"
317
+ className={css.ghostButton}
318
+ disabled={current.status === status || running || pending}
319
+ onClick={() => { controller.moveTask(current.id, status) }}
320
+ >
321
+ {t(`status.move.${status}` as TaskBoardKey)}
322
+ </button>
323
+ ))}
324
+ </div>
325
+ </section>
326
+ )}
304
327
  </div>
305
328
 
306
329
  <footer className={css.detailFooter}>
307
- <button
308
- type="button"
309
- className={css.primaryButton}
310
- disabled={running}
311
- onClick={() => {
312
- // Running kicks off a real agent session; close the detail so
313
- // the whole board stays visible while the task executes.
314
- controller.closeTask()
315
- void controller.rerunTask(current.id)
316
- }}
317
- >
318
- {current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
319
- </button>
320
- {current.archivedAt !== undefined ? (
330
+ {!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
331
+ {!archived && (
332
+ <button
333
+ type="button"
334
+ className={css.primaryButton}
335
+ disabled={running || pending}
336
+ onClick={() => {
337
+ void controller.rerunTask(current.id).then(() => {
338
+ if (controller.getSnapshot().transportError === undefined) controller.closeTask()
339
+ })
340
+ }}
341
+ >
342
+ {current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
343
+ </button>
344
+ )}
345
+ {archived ? (
321
346
  <button
322
347
  type="button"
323
348
  className={css.primaryButton}
349
+ disabled={pending}
324
350
  onClick={() => {
325
351
  controller.restoreTask(current.id)
326
- controller.closeTask()
327
352
  }}
328
353
  >
329
354
  {t('detail.restore')}
@@ -333,9 +358,9 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
333
358
  <button
334
359
  type="button"
335
360
  className={css.ghostButton}
361
+ disabled={pending}
336
362
  onClick={() => {
337
363
  controller.archiveTask(current.id)
338
- controller.closeTask()
339
364
  }}
340
365
  >
341
366
  {t('detail.archive')}
@@ -345,13 +370,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
345
370
  <button
346
371
  type="button"
347
372
  className={css.dangerButton}
373
+ disabled={pending}
348
374
  onClick={() => { setConfirmDelete(true) }}
349
375
  >
350
376
  {t('detail.delete')}
351
377
  </button>
352
378
  <span className={css.detailMeta}>
353
- {t('board.created')} {formatTime(current.createdAt)}
354
- {current.archivedAt !== undefined && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt) })}`}
379
+ {t('board.created')} {formatTime(current.createdAt, timeZone)}
380
+ {archived && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt!, timeZone) })}`}
355
381
  </span>
356
382
  </footer>
357
383
  </div>
@@ -366,7 +392,6 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
366
392
  onConfirm={() => {
367
393
  setConfirmDelete(false)
368
394
  controller.deleteTask(current.id)
369
- controller.closeTask()
370
395
  }}
371
396
  />
372
397
  )}
@@ -0,0 +1,11 @@
1
+ import type { TaskStatus } from '../../core/tasks.ts'
2
+ import type { TaskBoardKey } from '../locales.ts'
3
+
4
+ /** Task status → locale key (board column titles and the detail badge). */
5
+ export const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
6
+ backlog: 'board.status.backlog',
7
+ todo: 'board.status.todo',
8
+ running: 'board.status.running',
9
+ done: 'board.status.done',
10
+ failed: 'board.status.failed',
11
+ }
@@ -0,0 +1,95 @@
1
+ import type { TaskRecord } from '../core/tasks.ts'
2
+ import {
3
+ TASK_BOARD_API_PREFIX,
4
+ type TaskBoardAction,
5
+ type TaskBoardActionEnvelope,
6
+ type TaskBoardSnapshot,
7
+ } from '../protocol.ts'
8
+
9
+ const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
10
+ const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
11
+ const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
12
+ const REQUEST_TIMEOUT_MS = 15_000
13
+
14
+ function uuid(): string {
15
+ return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
16
+ }
17
+
18
+ async function readJson<T>(response: Response): Promise<T> {
19
+ const body = await response.json() as T & { error?: string }
20
+ if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`)
21
+ return body
22
+ }
23
+
24
+ export interface TaskBoardHostTransport {
25
+ bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
26
+ state(): Promise<TaskBoardSnapshot>
27
+ action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
28
+ subscribe(listener: () => void): () => void
29
+ }
30
+
31
+ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
32
+ constructor(private readonly storage: Pick<Storage, 'getItem' | 'setItem'> | undefined = globalThis.localStorage) {}
33
+
34
+ async bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot> {
35
+ const initial = await this.state()
36
+ const ledgerId = initial.scheduler.ledgerId
37
+ if (legacy.length > 0 && ledgerId !== undefined && this.storage?.getItem(IMPORT_MARKER) !== ledgerId) {
38
+ let sourceId = this.storage?.getItem(SOURCE_KEY)
39
+ if (sourceId === null || sourceId === undefined || sourceId === '') {
40
+ sourceId = uuid()
41
+ this.storage?.setItem(SOURCE_KEY, sourceId)
42
+ }
43
+ let requestId = this.storage?.getItem(IMPORT_REQUEST_KEY)
44
+ if (requestId === null || requestId === undefined || requestId === '') {
45
+ requestId = uuid()
46
+ this.storage?.setItem(IMPORT_REQUEST_KEY, requestId)
47
+ }
48
+ const snapshot = await this.post(requestId, { kind: 'import', sourceId, tasks: [...legacy] })
49
+ this.storage?.setItem(IMPORT_MARKER, snapshot.scheduler.ledgerId ?? ledgerId)
50
+ return snapshot
51
+ }
52
+ return initial
53
+ }
54
+
55
+ async state(): Promise<TaskBoardSnapshot> {
56
+ return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
57
+ }
58
+
59
+ async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
60
+ return await this.post(uuid(), action)
61
+ }
62
+
63
+ private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
64
+ const envelope: TaskBoardActionEnvelope = { requestId, action }
65
+ return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify(envelope),
69
+ })
70
+ }
71
+
72
+ private async request(url: string, init: RequestInit): Promise<TaskBoardSnapshot> {
73
+ const controller = new AbortController()
74
+ const timeout = globalThis.setTimeout(() => { controller.abort() }, REQUEST_TIMEOUT_MS)
75
+ try {
76
+ return await readJson<TaskBoardSnapshot>(await fetch(url, { ...init, signal: controller.signal }))
77
+ } catch (error) {
78
+ if (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1_000}s`)
79
+ throw error
80
+ } finally {
81
+ globalThis.clearTimeout(timeout)
82
+ }
83
+ }
84
+
85
+ subscribe(listener: () => void): () => void {
86
+ const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
87
+ events.onmessage = () => { listener() }
88
+ const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
89
+ document.addEventListener('visibilitychange', onVisible)
90
+ return () => {
91
+ document.removeEventListener('visibilitychange', onVisible)
92
+ events.close()
93
+ }
94
+ }
95
+ }
@@ -8,8 +8,8 @@
8
8
  * shell fails the whole boot when a plugin apply throws, and an external
9
9
  * plugin must not take the GUI down.
10
10
  */
11
- import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
12
- import type { ConnectionHandle, PromptContentPart } from '@deepseek-ai/dsh-client-connection/client'
11
+ import type { ClientContext, ISessions, IWorkspaces, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
13
13
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
14
14
  // Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
15
15
  // LocaleNamespaceMap merge table.
@@ -17,14 +17,13 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
17
17
  // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
18
18
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
19
19
  import { BoardController } from '../core/controller.ts'
20
- import { ExecutionService } from '../core/execution.ts'
21
- import { SchedulerService } from '../core/scheduler.ts'
22
20
  import { LocalStorageTaskStore } from '../core/store.ts'
23
21
  import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
24
22
  import { mountBoard } from './board-mount.tsx'
25
23
  import { mountSidebarEntry } from './sidebar-entry.ts'
26
24
  import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
27
25
  import { en, zh, type TaskBoardKey } from './locales.ts'
26
+ import { HttpTaskBoardHostTransport } from './host-api.ts'
28
27
 
29
28
  /** Locale namespace this plugin owns. */
30
29
  const NS = 'task-board'
@@ -113,64 +112,18 @@ export function apply(ctx: ClientContext): void {
113
112
  let uiDisposer: (() => void) | undefined
114
113
  const mountUi = (): void => {
115
114
  if (uiDisposer !== undefined) return
116
- const sessions = ctx.sessions
117
- const workspaces = ctx.workspaces
115
+ // Host and browser SDK declarations share the Cordis Context name. Read
116
+ // the browser faces explicitly so Host-side declaration merging cannot
117
+ // narrow these two client services during a combined package build.
118
+ const sessions = ctx.get('sessions') as unknown as ISessions
119
+ const workspaces = ctx.get('workspaces') as unknown as IWorkspaces
118
120
  const connection = ctx.get('connection') as ConnectionHandle
119
121
 
120
122
  // Core wiring: real runtime faces into the framework-free services.
121
123
  const store = new LocalStorageTaskStore()
122
- const exec = new ExecutionService({
123
- sessions: {
124
- list: sessions.list,
125
- binding: id => {
126
- const binding = sessions.binding(id as SessionId)
127
- if (binding === undefined) return undefined
128
- const { session } = binding
129
- return {
130
- session: {
131
- rename: title => session.rename(title),
132
- prompt: (content, mode) =>
133
- session.prompt(content as PromptContentPart[], mode).then(result =>
134
- result.ok ? { ok: true as const } : { ok: false as const, error: result.error }),
135
- command: line =>
136
- session.command(line).then(result =>
137
- result.ok ? { ok: true as const, matched: result.value.matched } : { ok: false as const, error: result.error }),
138
- getSnapshot: () => session.getSnapshot(),
139
- subscribe: fn => session.subscribe(fn),
140
- },
141
- }
142
- },
143
- noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId as SessionId, agentPreset),
144
- },
145
- workspaces: {
146
- list: workspaces.list,
147
- connectWorkspace: id => workspaces.connectWorkspace(id as WorkspaceId),
148
- },
149
- presets: {
150
- select: async (sessionId, agentPreset) => {
151
- try {
152
- const response = await connection.api.agentPresets.select({ sessionId: sessionId as SessionId, agentPreset })
153
- return response.result.ok ? { ok: true as const } : { ok: false as const, error: response.result.error }
154
- } catch (error) {
155
- return { ok: false as const, error }
156
- }
157
- },
158
- },
159
- history: {
160
- loadTail: async sessionId => {
161
- const response = await connection.api.sessions.history({
162
- sessionId: sessionId as SessionId,
163
- maxMessages: 20,
164
- })
165
- return response.result.ok
166
- ? { events: response.result.value.events.map(entry => entry.event) }
167
- : undefined
168
- },
169
- },
170
- })
171
124
  const controller = new BoardController({
172
125
  store,
173
- exec,
126
+ transport: new HttpTaskBoardHostTransport(),
174
127
  sessions: {
175
128
  list: sessions.list,
176
129
  open: id => sessions.open(id as SessionId),
@@ -178,27 +131,6 @@ export function apply(ctx: ClientContext): void {
178
131
  })
179
132
  controller.start()
180
133
 
181
- // Scheduled runs: a browser-side heartbeat that triggers due tasks through
182
- // the same run path as the manual Run button. The first tick is gated on
183
- // the session list baseline so a page-load catch-up never fires into a
184
- // not-yet-ready runtime; tab visibility recovery ticks immediately.
185
- const scheduler = new SchedulerService({
186
- tasks: () => controller.getSnapshot().tasks,
187
- // Re-read the persisted ledger before every fire decision: a task
188
- // deleted in another tab must never fire from a stale in-memory copy.
189
- refresh: () => controller.reloadFromStore(),
190
- now: () => Date.now(),
191
- runTask: id => controller.runTask(id),
192
- applySchedule: (id, nextRunAt, lastTriggeredAt) =>
193
- controller.applyScheduleNextRun(id, nextRunAt, lastTriggeredAt),
194
- ready: () => sessions.list.getSnapshot().phase === 'ready',
195
- environment: {
196
- addEventListener: (type, listener) => document.addEventListener(type, listener),
197
- removeEventListener: (type, listener) => document.removeEventListener(type, listener),
198
- },
199
- })
200
- scheduler.start()
201
-
202
134
  const disposers: Array<() => void> = []
203
135
 
204
136
  // Execution-target option feeds: the workspace list drives the workspace
@@ -248,7 +180,6 @@ export function apply(ctx: ClientContext): void {
248
180
 
249
181
  uiDisposer = () => {
250
182
  for (const dispose of disposers.splice(0)) dispose()
251
- scheduler.dispose()
252
183
  controller.dispose()
253
184
  uiDisposer = undefined
254
185
  }
@@ -24,9 +24,13 @@ export const zh = {
24
24
  'board.status.running': '进行中',
25
25
  'board.status.done': '已完成',
26
26
  'board.status.failed': '已失败',
27
- 'board.runs': '次执行',
27
+ 'board.runs': '次执行',
28
+ 'board.pending': '正在提交',
28
29
  'board.updated': '更新于',
29
30
  'board.created': '创建于',
31
+ 'board.hostError': 'Host 操作失败:{error}',
32
+ 'board.retryHost': '重试连接 Host',
33
+ 'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
30
34
  'new.title': '标题',
31
35
  'new.titlePlaceholder': '一句话描述要做什么',
32
36
  'new.description': '描述',
@@ -96,11 +100,17 @@ export const zh = {
96
100
  'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
97
101
  // 插件设置卡片(settings.plugin.item 席位)。
98
102
  'settings.title': '任务看板',
99
- 'settings.description': '控制看板在 agent 系统提示中的播报行为。',
103
+ 'settings.description': '控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。',
100
104
  'settings.enabled': '启用任务看板',
101
105
  'settings.enabledHint': '关闭后隐藏侧边栏入口与看板视图。',
102
106
  'settings.announceToAgent': '向 agent 播报任务看板',
103
107
  'settings.announceToAgentHint': '开启:每条 agent 系统提示都会包含本看板的说明;关闭:不播报,agent 仅在用户主动提及时了解看板。',
108
+ 'settings.preventIdleSleep': '阻止系统空闲睡眠',
109
+ 'settings.preventIdleSleepHint': '默认关闭。开启后,只要存在运行中的 DSH 会话、已启用的定时任务或会话状态尚未确认,Host 就阻止整机因空闲睡眠;屏幕仍可自动关闭。',
110
+ 'settings.powerStatus': '平台:{platform};保护状态:{phase};运行会话:{running};已启用计划:{schedules}',
111
+ 'settings.powerBoundary': '这可能增加电池消耗。合盖、手动睡眠、休眠、关机、低电量或企业策略不在保证范围内,也不会唤醒已经睡眠的机器。',
112
+ 'settings.powerUnknown': '未知',
113
+ 'settings.powerError': '最近一次电源保护错误:{error}',
104
114
  'settings.inherit': '继承',
105
115
  'settings.on': '开',
106
116
  'settings.off': '关',
@@ -137,9 +147,13 @@ export const en: Record<keyof typeof zh, string> = {
137
147
  'board.status.running': 'In Progress',
138
148
  'board.status.done': 'Done',
139
149
  'board.status.failed': 'Failed',
140
- 'board.runs': 'runs',
150
+ 'board.runs': 'runs',
151
+ 'board.pending': 'Submitting',
141
152
  'board.updated': 'Updated',
142
153
  'board.created': 'Created',
154
+ 'board.hostError': 'Host action failed: {error}',
155
+ 'board.retryHost': 'Retry Host connection',
156
+ 'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
143
157
  'new.title': 'Title',
144
158
  'new.titlePlaceholder': 'What should be done, in one line',
145
159
  'new.description': 'Description',
@@ -209,11 +223,17 @@ export const en: Record<keyof typeof zh, string> = {
209
223
  'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
210
224
  // Plugin settings card (the `settings.plugin.item` seat).
211
225
  'settings.title': 'Task Board',
212
- 'settings.description': 'How the board announces itself in each agent system prompt.',
226
+ 'settings.description': 'Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.',
213
227
  'settings.enabled': 'Enable the task board',
214
228
  'settings.enabledHint': 'When off, the sidebar entry and board view are hidden.',
215
229
  'settings.announceToAgent': 'Announce the task board to agents',
216
230
  'settings.announceToAgentHint': 'On: every agent system prompt includes a note about this board. Off: no announcement; agents learn about the board only when you mention it.',
231
+ 'settings.preventIdleSleep': 'Prevent idle system sleep',
232
+ 'settings.preventIdleSleepHint': 'Off by default. When enabled, the Host prevents idle system sleep while any DSH session runs, any schedule is enabled, or session state is not yet known. The display may still turn off.',
233
+ 'settings.powerStatus': 'Platform: {platform}; protection: {phase}; running sessions: {running}; enabled schedules: {schedules}',
234
+ 'settings.powerBoundary': 'This may use more battery. Lid close, manual sleep, hibernation, shutdown, low-battery actions, and enterprise policy are outside the guarantee; an already sleeping computer is not woken.',
235
+ 'settings.powerUnknown': 'unknown',
236
+ 'settings.powerError': 'Latest power-protection error: {error}',
217
237
  'settings.inherit': 'Inherit',
218
238
  'settings.on': 'On',
219
239
  'settings.off': 'Off',