@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
package/lib/index.js CHANGED
@@ -1,11 +1,2156 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { dirname, isAbsolute, join, win32 } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ //#region src/core/schedule.ts
9
+ /** Inclusive ranges per field, in cron order. */
10
+ const FIELD_RANGES = [
11
+ [0, 59],
12
+ [0, 23],
13
+ [1, 31],
14
+ [1, 12],
15
+ [0, 7]
16
+ ];
17
+ /**
18
+ * Parse a 5-field cron expression.
19
+ * @returns the match sets, or null when the expression is invalid.
20
+ */
21
+ function parseCron(expr) {
22
+ const fields = expr.trim().split(/\s+/);
23
+ if (fields.length !== 5) return null;
24
+ const sets = [];
25
+ for (let index = 0; index < 5; index++) {
26
+ const [min, max] = FIELD_RANGES[index];
27
+ const set = /* @__PURE__ */ new Set();
28
+ if (!parseField(fields[index], min, max, set)) return null;
29
+ sets.push(set);
30
+ }
31
+ const weekdays = /* @__PURE__ */ new Set();
32
+ for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
33
+ return {
34
+ minutes: sets[0],
35
+ hours: sets[1],
36
+ days: sets[2],
37
+ months: sets[3],
38
+ weekdays,
39
+ dayWildcard: fields[2] === "*",
40
+ weekdayWildcard: fields[4] === "*"
41
+ };
42
+ }
43
+ /** Whether the expression parses. */
44
+ function isValidCron(expr) {
45
+ return parseCron(expr) !== null;
46
+ }
47
+ /**
48
+ * Compute the next matching instant after `fromMs` (ms epoch), in local time,
49
+ * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
50
+ * of the matching minute's start, or undefined when the calendar constraint
51
+ * can never match (for example `0 0 30 2 *`). The five-year horizon includes
52
+ * a full leap cycle, so a valid February 29 schedule remains reachable from
53
+ * every non-leap year.
54
+ */
55
+ function nextRunAtMs(expr, fromMs) {
56
+ const schedule = parseCron(expr);
57
+ if (schedule === null) return void 0;
58
+ if (!hasPossibleCalendarDay(schedule)) return void 0;
59
+ const from = new Date(fromMs);
60
+ const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
61
+ const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
62
+ while (scan.getTime() <= limitMs) {
63
+ if (matches(schedule, scan)) return scan.getTime();
64
+ scan.setMinutes(scan.getMinutes() + 1);
65
+ }
66
+ }
67
+ /** Reject impossible month/day pairs without spending the multi-year scan. */
68
+ function hasPossibleCalendarDay(schedule) {
69
+ if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
70
+ const maximumDay = /* @__PURE__ */ new Map([
71
+ [1, 31],
72
+ [2, 29],
73
+ [3, 31],
74
+ [4, 30],
75
+ [5, 31],
76
+ [6, 30],
77
+ [7, 31],
78
+ [8, 31],
79
+ [9, 30],
80
+ [10, 31],
81
+ [11, 30],
82
+ [12, 31]
83
+ ]);
84
+ for (const month of schedule.months) {
85
+ const maximum = maximumDay.get(month) ?? 0;
86
+ if ([...schedule.days].some((day) => day <= maximum)) return true;
87
+ }
88
+ return false;
89
+ }
90
+ /** Parse one comma-list field into the match set. */
91
+ function parseField(field, min, max, out) {
92
+ if (field === "*") {
93
+ for (let value = min; value <= max; value++) out.add(value);
94
+ return true;
95
+ }
96
+ for (const part of field.split(",")) {
97
+ if (part === "") return false;
98
+ const [range, stepRaw] = part.split("/");
99
+ let low;
100
+ let high;
101
+ if (range === "*") {
102
+ low = min;
103
+ high = max;
104
+ } else if (range.includes("-")) {
105
+ const [a, b] = range.split("-");
106
+ if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
107
+ low = Number(a);
108
+ high = Number(b);
109
+ } else if (isDigits(range)) {
110
+ low = Number(range);
111
+ high = Number(range);
112
+ } else return false;
113
+ if (low < min || high > max || low > high) return false;
114
+ const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
115
+ if (!Number.isInteger(step) || step < 1) return false;
116
+ for (let value = low; value <= high; value += step) out.add(value);
117
+ }
118
+ return true;
119
+ }
120
+ /** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
121
+ function matches(schedule, date) {
122
+ if (!schedule.minutes.has(date.getMinutes())) return false;
123
+ if (!schedule.hours.has(date.getHours())) return false;
124
+ if (!schedule.months.has(date.getMonth() + 1)) return false;
125
+ const dayMatches = schedule.days.has(date.getDate());
126
+ const weekdayMatches = schedule.weekdays.has(date.getDay());
127
+ if (schedule.dayWildcard) return weekdayMatches;
128
+ if (schedule.weekdayWildcard) return dayMatches;
129
+ return dayMatches || weekdayMatches;
130
+ }
131
+ function isDigits(value) {
132
+ return /^\d+$/.test(value);
133
+ }
134
+ //#endregion
135
+ //#region src/dsh-home.ts
136
+ /**
137
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
138
+ * environment override wins, the platform home fallback follows. Mirrors
139
+ * what dsh-pet and dsh-liangshen each used to implement locally.
140
+ */
141
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
142
+ function expandHome(path, home = homedir()) {
143
+ if (path === "~") return home;
144
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
145
+ return path;
146
+ }
147
+ /**
148
+ * Resolve the DSH home directory.
149
+ * @param env - process environment to read DSH_HOME from.
150
+ * @param home - platform home directory fallback (test seam).
151
+ * @returns the absolute DSH home path.
152
+ */
153
+ function resolveDshHome(env = process.env, home = homedir()) {
154
+ const raw = env.DSH_HOME;
155
+ if (raw !== void 0 && raw.trim() !== "") {
156
+ const expanded = expandHome(raw.trim(), home);
157
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
158
+ }
159
+ return join(home, ".dsh");
160
+ }
161
+ /** Resolve the DSH home directory from the live environment. */
162
+ function dshHome() {
163
+ return resolveDshHome();
164
+ }
165
+ //#endregion
166
+ //#region src/core/tasks.ts
167
+ /** Statuses a settled task may be archived from. */
168
+ const ARCHIVABLE_STATUSES = ["done", "failed"];
169
+ /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
170
+ const TASK_PERMISSIONS = [
171
+ "read-only",
172
+ "workspace-write",
173
+ "danger-full-access"
174
+ ];
175
+ /** Whether an unknown value is a known permission preset id. */
176
+ function isTaskPermission(value) {
177
+ return typeof value === "string" && TASK_PERMISSIONS.includes(value);
178
+ }
179
+ /** Statuses a user may move a card to manually (execution states are owned by the runner). */
180
+ const MANUAL_STATUSES = ["backlog", "todo"];
181
+ /** All valid statuses (closed union guard). */
182
+ const ALL_STATUSES = [
183
+ "backlog",
184
+ "todo",
185
+ "running",
186
+ "done",
187
+ "failed"
188
+ ];
189
+ /** Brand an unknown string as a status; undefined when it is not one. */
190
+ function isTaskStatus(value) {
191
+ return typeof value === "string" && ALL_STATUSES.includes(value);
192
+ }
193
+ /** Whether a manual move target is allowed from the given status. */
194
+ function canMoveManually(from, to) {
195
+ return from !== "running" && MANUAL_STATUSES.includes(to);
196
+ }
197
+ /** Normalize one optional execution-target string: trim; blank collapses to undefined. */
198
+ function normalizeTargetId(value) {
199
+ const trimmed = value?.trim();
200
+ return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
201
+ }
202
+ /** Create a task from user input. */
203
+ function createTask(input, now, id) {
204
+ return {
205
+ id,
206
+ title: input.title.trim(),
207
+ description: input.description.trim(),
208
+ prompt: input.prompt.trim(),
209
+ status: "todo",
210
+ createdAt: now,
211
+ updatedAt: now,
212
+ executions: [],
213
+ workspaceId: normalizeTargetId(input.workspaceId),
214
+ mode: normalizeTargetId(input.mode),
215
+ permission: isTaskPermission(input.permission) ? input.permission : void 0
216
+ };
217
+ }
218
+ /** Clone a task with an updated status and a fresh updatedAt. */
219
+ function withStatus(task, status, now) {
220
+ return {
221
+ ...task,
222
+ status,
223
+ updatedAt: now
224
+ };
225
+ }
226
+ /**
227
+ * Merge a schedule patch into a task's schedule rule (creating it when
228
+ * absent), with a fresh updatedAt. Keys present in the patch overwrite the
229
+ * current value — including explicit `undefined`, which clears a field (used
230
+ * to disarm `nextRunAt`); absent keys keep their current value.
231
+ */
232
+ function withSchedule(task, patch, now) {
233
+ const current = task.schedule;
234
+ const schedule = {
235
+ enabled: current?.enabled ?? false,
236
+ cron: current?.cron ?? "",
237
+ nextRunAt: current?.nextRunAt,
238
+ lastTriggeredAt: current?.lastTriggeredAt
239
+ };
240
+ if ("enabled" in patch) schedule.enabled = patch.enabled ?? false;
241
+ if ("cron" in patch) schedule.cron = patch.cron ?? "";
242
+ if ("nextRunAt" in patch) schedule.nextRunAt = patch.nextRunAt;
243
+ if ("lastTriggeredAt" in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt;
244
+ return {
245
+ ...task,
246
+ updatedAt: now,
247
+ schedule
248
+ };
249
+ }
250
+ /**
251
+ * Open a fresh execution on a task: move it to 'running' and append a
252
+ * running execution record. Returns the new task and the new execution.
253
+ */
254
+ function startExecution(task, now, executionId) {
255
+ const execution = {
256
+ id: executionId,
257
+ sessionId: void 0,
258
+ startedAt: now,
259
+ endedAt: void 0,
260
+ result: void 0,
261
+ error: void 0
262
+ };
263
+ return {
264
+ task: {
265
+ ...task,
266
+ status: "running",
267
+ updatedAt: now,
268
+ executions: [...task.executions, execution]
269
+ },
270
+ execution
271
+ };
272
+ }
273
+ /**
274
+ * Settle a running execution: record the outcome and move the task into the
275
+ * matching column. No-op (returns the input task) when the execution is not
276
+ * the task's latest or is already settled.
277
+ */
278
+ function settleExecution(task, executionId, outcome, now, error) {
279
+ const index = task.executions.findIndex((execution) => execution.id === executionId);
280
+ if (index === -1) return task;
281
+ const execution = task.executions[index];
282
+ if (execution.endedAt !== void 0) return task;
283
+ const settled = {
284
+ ...execution,
285
+ endedAt: now,
286
+ result: outcome,
287
+ error
288
+ };
289
+ const executions = [...task.executions];
290
+ executions[index] = settled;
291
+ const status = outcome === "succeeded" ? "done" : outcome === "failed" ? "failed" : task.status === "running" ? "todo" : task.status;
292
+ return {
293
+ ...task,
294
+ status,
295
+ updatedAt: now,
296
+ executions
297
+ };
298
+ }
299
+ //#endregion
300
+ //#region src/core/store.ts
301
+ /**
302
+ * Legacy v1 browser persistence and the store seam used by pure client tests.
303
+ *
304
+ * Production v2 state is Host-authoritative. This backend is retained only to
305
+ * read `dsh.taskBoard.v1` for one-time import; the old value is never removed,
306
+ * so it remains a read-only rollback copy after migration.
307
+ *
308
+ * The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
309
+ * channel later); tests run against the in-memory backend and a jsdom
310
+ * localStorage backend.
311
+ */
312
+ /**
313
+ * Structural row check with the status left unvalidated (see {@link parseLedger}).
314
+ * The `schedule` field is deliberately NOT checked here: a malformed schedule
315
+ * never drops the task row — {@link normalizeSchedule} repairs or drops the
316
+ * schedule alone.
317
+ */
318
+ function isTaskRecordShape(value) {
319
+ if (typeof value !== "object" || value === null) return false;
320
+ const record = value;
321
+ if (typeof record.id !== "string" || record.id === "") return false;
322
+ if (typeof record.title !== "string") return false;
323
+ if (typeof record.description !== "string") return false;
324
+ if (typeof record.prompt !== "string") return false;
325
+ if (typeof record.createdAt !== "number") return false;
326
+ if (typeof record.updatedAt !== "number") return false;
327
+ if (record.workspaceId !== void 0 && typeof record.workspaceId !== "string") return false;
328
+ if (record.mode !== void 0 && typeof record.mode !== "string") return false;
329
+ if (record.permission !== void 0 && typeof record.permission !== "string") return false;
330
+ if (!Array.isArray(record.executions)) return false;
331
+ for (const execution of record.executions) {
332
+ if (typeof execution !== "object" || execution === null) return false;
333
+ const entry = execution;
334
+ if (typeof entry.id !== "string") return false;
335
+ if (entry.sessionId !== void 0 && typeof entry.sessionId !== "string") return false;
336
+ if (typeof entry.startedAt !== "number") return false;
337
+ if (entry.endedAt !== void 0 && typeof entry.endedAt !== "number") return false;
338
+ if (entry.result !== void 0 && entry.result !== "succeeded" && entry.result !== "failed" && entry.result !== "cancelled") return false;
339
+ if (entry.error !== void 0 && typeof entry.error !== "string") return false;
340
+ }
341
+ return true;
342
+ }
343
+ /** Normalize an unknown persisted status back into the closed status union. */
344
+ function normalizeStatus(status) {
345
+ return isTaskStatus(status) ? status : "todo";
346
+ }
347
+ /**
348
+ * Repair a persisted schedule rule: drop rules without a usable cron string,
349
+ * coerce booleans/numbers, and leave `nextRunAt`/`lastTriggeredAt` undefined
350
+ * when missing (a fresh recompute or the next tick fixes them).
351
+ */
352
+ function normalizeSchedule(schedule) {
353
+ if (typeof schedule !== "object" || schedule === null) return void 0;
354
+ const rule = schedule;
355
+ if (typeof rule.cron !== "string") return void 0;
356
+ if (rule.cron.trim() === "" || !isValidCron(rule.cron)) return void 0;
357
+ return {
358
+ enabled: rule.enabled === true,
359
+ cron: rule.cron,
360
+ nextRunAt: typeof rule.nextRunAt === "number" ? rule.nextRunAt : void 0,
361
+ lastTriggeredAt: typeof rule.lastTriggeredAt === "number" ? rule.lastTriggeredAt : void 0
362
+ };
363
+ }
364
+ /** Parse + validate a persisted ledger document; invalid rows are dropped. */
365
+ function parseLedger(raw) {
366
+ if (raw === null) return [];
367
+ let parsed;
368
+ try {
369
+ parsed = JSON.parse(raw);
370
+ } catch (error) {
371
+ console.error("[dsh-task-board] persisted task ledger is not valid JSON; starting empty", error);
372
+ return [];
373
+ }
374
+ if (!Array.isArray(parsed)) {
375
+ console.error("[dsh-task-board] persisted task ledger is not an array; starting empty");
376
+ return [];
377
+ }
378
+ const tasks = [];
379
+ for (const row of parsed) {
380
+ if (!isTaskRecordShape(row)) {
381
+ console.warn("[dsh-task-board] dropping invalid task row from persisted ledger", row);
382
+ continue;
383
+ }
384
+ const task = {
385
+ ...row,
386
+ status: normalizeStatus(row.status)
387
+ };
388
+ task.schedule = normalizeSchedule(row.schedule);
389
+ task.workspaceId = normalizeTargetId(row.workspaceId);
390
+ task.mode = normalizeTargetId(row.mode);
391
+ task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
392
+ task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
393
+ tasks.push(task);
394
+ }
395
+ return tasks;
396
+ }
397
+ //#endregion
398
+ //#region src/core/use-cases/task-archive.ts
399
+ /**
400
+ * Archive one task: only settled statuses (done/failed) can be archived;
401
+ * a running or not-yet-settled task stays on the board (its runner still
402
+ * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
403
+ * are a no-op.
404
+ */
405
+ function applyArchiveTask(tasks, id, now) {
406
+ let applied = false;
407
+ return {
408
+ tasks: tasks.map((task) => {
409
+ if (task.id !== id || task.archivedAt !== void 0) return task;
410
+ if (!ARCHIVABLE_STATUSES.includes(task.status)) return task;
411
+ applied = true;
412
+ const schedule = task.schedule === void 0 ? void 0 : {
413
+ ...task.schedule,
414
+ enabled: false,
415
+ nextRunAt: void 0
416
+ };
417
+ return {
418
+ ...task,
419
+ ...schedule === void 0 ? {} : { schedule },
420
+ archivedAt: now,
421
+ updatedAt: now
422
+ };
423
+ }),
424
+ archived: applied
425
+ };
426
+ }
427
+ /** Restore one task back onto the main board (clears the archive marker). */
428
+ function applyRestoreTask(tasks, id, now) {
429
+ let applied = false;
430
+ return {
431
+ tasks: tasks.map((task) => {
432
+ if (task.id !== id || task.archivedAt === void 0) return task;
433
+ applied = true;
434
+ const { archivedAt: _archived, ...rest } = task;
435
+ return {
436
+ ...rest,
437
+ updatedAt: now
438
+ };
439
+ }),
440
+ archived: applied
441
+ };
442
+ }
443
+ //#endregion
444
+ //#region src/core/use-cases/task-create.ts
445
+ /**
446
+ * Create-task use case: mint a new task from user input, rejecting a blank
447
+ * title. Pure ledger transition (no persistence or notify — the controller
448
+ * orchestrates those), so it is unit-testable without any runtime face.
449
+ */
450
+ /**
451
+ * Apply a create against the current ledger. Returns the new task and the
452
+ * appended ledger, or the unchanged ledger when the title is blank.
453
+ * @param tasks - current ledger.
454
+ * @param input - raw user input (title/description/prompt + optional schedule).
455
+ * @param now - clock instant (ms epoch).
456
+ * @param id - minted task id.
457
+ */
458
+ function applyCreateTask(tasks, input, now, id) {
459
+ if (input.title.trim() === "") return {
460
+ task: void 0,
461
+ tasks
462
+ };
463
+ let task = createTask(input, now, id);
464
+ const requested = input.schedule;
465
+ if (requested?.enabled === true && requested.cron.trim() !== "" && isValidCron(requested.cron)) {
466
+ const cron = requested.cron.trim();
467
+ task = withSchedule(task, {
468
+ enabled: true,
469
+ cron,
470
+ nextRunAt: nextRunAtMs(cron, now)
471
+ }, now);
472
+ }
473
+ return {
474
+ task,
475
+ tasks: [...tasks, task]
476
+ };
477
+ }
478
+ //#endregion
479
+ //#region src/core/use-cases/task-delete.ts
480
+ /**
481
+ * Apply a delete across the ledger. The selection (a task id) is cleared when
482
+ * it matches the removed task, so the UI never lingers on a vanished detail.
483
+ * @param tasks - current ledger.
484
+ * @param selectedTaskId - the currently selected task id (may be undefined).
485
+ * @param id - the task to remove.
486
+ */
487
+ function applyDeleteTask(tasks, selectedTaskId, id) {
488
+ return {
489
+ tasks: tasks.filter((task) => task.id !== id),
490
+ selectionCleared: selectedTaskId === id
491
+ };
492
+ }
493
+ //#endregion
494
+ //#region src/core/use-cases/task-schedule.ts
495
+ /**
496
+ * Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
497
+ * Pure ledger transitions (no persistence or notify — the controller
498
+ * orchestrates those). Validation and next-run computation live here, sharing
499
+ * the core cron parser (schedule.ts) and the withSchedule transition.
500
+ */
501
+ /**
502
+ * Set an on-board task's schedule rule. A blank or invalid cron, or an
503
+ * archived task, is rejected (state untouched); an enabled rule computes the
504
+ * next run instant immediately, a disabled one carries no next-run instant.
505
+ * @param tasks - current ledger.
506
+ * @param id - the task to schedule.
507
+ * @param patch - rule fields to change (absent fields keep their current value).
508
+ * @param now - clock instant (ms epoch).
509
+ */
510
+ function applySetSchedule(tasks, id, patch, now) {
511
+ const task = tasks.find((candidate) => candidate.id === id);
512
+ if (task === void 0 || task.archivedAt !== void 0) return {
513
+ tasks,
514
+ applied: false
515
+ };
516
+ const current = task.schedule;
517
+ const cron = (patch.cron ?? current?.cron ?? "").trim();
518
+ if (cron === "" || !isValidCron(cron)) return {
519
+ tasks,
520
+ applied: false
521
+ };
522
+ const enabled = patch.enabled ?? current?.enabled ?? false;
523
+ const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
524
+ if (enabled && nextRunAt === void 0) return {
525
+ tasks,
526
+ applied: false
527
+ };
528
+ return {
529
+ tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
530
+ enabled,
531
+ cron,
532
+ nextRunAt
533
+ }, now) : candidate),
534
+ applied: true
535
+ };
536
+ }
537
+ /**
538
+ * Roll a task's schedule rule forward (scheduler callback): persist the next
539
+ * due instant and the trigger instant. No-op for tasks without a rule (deleted
540
+ * mid-tick, for example).
541
+ * @param tasks - current ledger.
542
+ * @param id - the task to roll forward.
543
+ * @param nextRunAt - next due instant (may be undefined to clear).
544
+ * @param lastTriggeredAt - the trigger instant of this run.
545
+ * @param now - clock instant (ms epoch).
546
+ */
547
+ function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
548
+ return tasks.map((task) => task.id === id && task.archivedAt === void 0 && task.schedule !== void 0 ? withSchedule(task, {
549
+ nextRunAt,
550
+ lastTriggeredAt
551
+ }, now) : task);
552
+ }
553
+ //#endregion
554
+ //#region src/core/use-cases/task-update.ts
555
+ /**
556
+ * Update-task use case: apply an editable-field patch (title/description/
557
+ * prompt plus the execution targets workspaceId/mode/permission) with a
558
+ * fresh updatedAt. Pure ledger transition (no persistence or notify — the
559
+ * controller orchestrates those).
560
+ *
561
+ * An explicit `undefined` in the patch clears the field (the task falls
562
+ * back to the runtime default); an unknown permission string is ignored so
563
+ * stale UI can never persist a value the execution service rejects.
564
+ */
565
+ /** Keep an unknown permission string from entering the ledger. */
566
+ function normalizePermission(current, value) {
567
+ if (value === void 0) return void 0;
568
+ return isTaskPermission(value) ? value : current;
569
+ }
570
+ /**
571
+ * Apply an update across the ledger. Tasks that do not match the id are left
572
+ * untouched; the matched task receives the patch plus a fresh updatedAt.
573
+ * @param tasks - current ledger.
574
+ * @param id - the task to update.
575
+ * @param patch - editable-field changes.
576
+ * @param now - clock instant (ms epoch).
577
+ */
578
+ function applyUpdateTask(tasks, id, patch, now) {
579
+ return tasks.map((task) => {
580
+ if (task.id !== id) return task;
581
+ const workspaceId = "workspaceId" in patch ? normalizeTargetId(patch.workspaceId) : void 0;
582
+ const mode = "mode" in patch ? normalizeTargetId(patch.mode) : void 0;
583
+ const permission = "permission" in patch ? normalizePermission(task.permission, patch.permission) : void 0;
584
+ const next = {
585
+ ...task,
586
+ ...patch,
587
+ updatedAt: now
588
+ };
589
+ if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
590
+ if (mode !== void 0 || "mode" in patch) next.mode = mode;
591
+ if (permission !== void 0 || "permission" in patch) next.permission = permission;
592
+ return next;
593
+ });
594
+ }
595
+ //#endregion
596
+ //#region src/protocol.ts
597
+ const TASK_BOARD_API_PREFIX = "/api/task-board";
598
+ function record(value) {
599
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
600
+ }
601
+ function exactKeys(value, allowed) {
602
+ return Object.keys(value).every((key) => allowed.includes(key));
603
+ }
604
+ function optionalString(value) {
605
+ return value === void 0 || typeof value === "string";
606
+ }
607
+ const FORBIDDEN_IMPORT_FIELDS = /* @__PURE__ */ new Set([
608
+ "args",
609
+ "command",
610
+ "executable",
611
+ "powershell",
612
+ "shell"
613
+ ]);
614
+ function hasForbiddenImportField(value) {
615
+ if (Array.isArray(value)) return value.some(hasForbiddenImportField);
616
+ const row = record(value);
617
+ if (row === void 0) return false;
618
+ return Object.entries(row).some(([key, nested]) => FORBIDDEN_IMPORT_FIELDS.has(key.toLowerCase()) || hasForbiddenImportField(nested));
619
+ }
620
+ function optionalFiniteNumber(value) {
621
+ return value === void 0 || typeof value === "number" && Number.isFinite(value);
622
+ }
623
+ function validImportedKnownFields(value) {
624
+ if (value.schedule !== void 0) {
625
+ const schedule = record(value.schedule);
626
+ if (schedule === void 0 || typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
627
+ if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
628
+ }
629
+ if (value.executions !== void 0) {
630
+ if (!Array.isArray(value.executions)) return false;
631
+ for (const item of value.executions) {
632
+ const execution = record(item);
633
+ if (execution === void 0 || typeof execution.id !== "string" || !optionalString(execution.sessionId)) return false;
634
+ if (typeof execution.startedAt !== "number" || !Number.isFinite(execution.startedAt)) return false;
635
+ if (!optionalFiniteNumber(execution.endedAt) || !optionalString(execution.error)) return false;
636
+ if (execution.result !== void 0 && ![
637
+ "succeeded",
638
+ "failed",
639
+ "cancelled"
640
+ ].includes(String(execution.result))) return false;
641
+ }
642
+ }
643
+ return true;
644
+ }
645
+ function importedTask(value) {
646
+ const input = record(value);
647
+ if (input === void 0 || hasForbiddenImportField(input) || !validImportedKnownFields(input)) return void 0;
648
+ const task = parseLedger(JSON.stringify([value]))[0];
649
+ if (task === void 0) return void 0;
650
+ return {
651
+ id: task.id,
652
+ title: task.title,
653
+ description: task.description,
654
+ prompt: task.prompt,
655
+ status: task.status,
656
+ createdAt: task.createdAt,
657
+ updatedAt: task.updatedAt,
658
+ executions: task.executions.map((execution) => ({
659
+ id: execution.id,
660
+ sessionId: execution.sessionId,
661
+ startedAt: execution.startedAt,
662
+ endedAt: execution.endedAt,
663
+ result: execution.result,
664
+ error: execution.error
665
+ })),
666
+ ...task.schedule === void 0 ? {} : { schedule: {
667
+ enabled: task.schedule.enabled,
668
+ cron: task.schedule.cron,
669
+ nextRunAt: task.schedule.nextRunAt,
670
+ lastTriggeredAt: task.schedule.lastTriggeredAt
671
+ } },
672
+ ...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
673
+ ...task.mode === void 0 ? {} : { mode: task.mode },
674
+ ...task.permission === void 0 ? {} : { permission: task.permission },
675
+ ...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt }
676
+ };
677
+ }
678
+ function createInput(value) {
679
+ const input = record(value);
680
+ if (input === void 0 || !exactKeys(input, [
681
+ "title",
682
+ "description",
683
+ "prompt",
684
+ "workspaceId",
685
+ "mode",
686
+ "permission",
687
+ "schedule"
688
+ ])) return false;
689
+ if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
690
+ if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false;
691
+ if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
692
+ if (input.schedule !== void 0) {
693
+ const schedule = record(input.schedule);
694
+ if (schedule === void 0 || !exactKeys(schedule, ["enabled", "cron"])) return false;
695
+ if (typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
696
+ }
697
+ return true;
698
+ }
699
+ function updatePatch(value) {
700
+ const patch = record(value);
701
+ if (patch === void 0 || !exactKeys(patch, [
702
+ "title",
703
+ "description",
704
+ "prompt",
705
+ "workspaceId",
706
+ "mode",
707
+ "permission"
708
+ ])) return false;
709
+ for (const key of [
710
+ "title",
711
+ "description",
712
+ "prompt",
713
+ "workspaceId",
714
+ "mode"
715
+ ]) if (!optionalString(patch[key])) return false;
716
+ return patch.permission === void 0 || isTaskPermission(patch.permission);
717
+ }
718
+ function schedulePatch(value) {
719
+ const patch = record(value);
720
+ return patch !== void 0 && exactKeys(patch, ["enabled", "cron"]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string");
721
+ }
722
+ function parseActionEnvelope(value) {
723
+ const envelope = record(value);
724
+ if (envelope === void 0 || !exactKeys(envelope, ["requestId", "action"])) return void 0;
725
+ if (typeof envelope.requestId !== "string" || envelope.requestId.trim() === "" || envelope.requestId.length > 256) return void 0;
726
+ const action = record(envelope.action);
727
+ if (action === void 0 || typeof action.kind !== "string") return void 0;
728
+ const taskId = typeof action.taskId === "string" && action.taskId !== "" ? action.taskId : void 0;
729
+ switch (action.kind) {
730
+ case "import":
731
+ if (!exactKeys(action, [
732
+ "kind",
733
+ "sourceId",
734
+ "tasks"
735
+ ])) return void 0;
736
+ if (typeof action.sourceId !== "string" || action.sourceId === "" || !Array.isArray(action.tasks)) return void 0;
737
+ {
738
+ const tasks = action.tasks.map(importedTask);
739
+ return tasks.every((task) => task !== void 0) ? {
740
+ requestId: envelope.requestId,
741
+ action: {
742
+ kind: "import",
743
+ sourceId: action.sourceId,
744
+ tasks
745
+ }
746
+ } : void 0;
747
+ }
748
+ case "create":
749
+ if (!exactKeys(action, [
750
+ "kind",
751
+ "id",
752
+ "input"
753
+ ])) return void 0;
754
+ return typeof action.id === "string" && action.id !== "" && createInput(action.input) ? {
755
+ requestId: envelope.requestId,
756
+ action
757
+ } : void 0;
758
+ case "update":
759
+ if (!exactKeys(action, [
760
+ "kind",
761
+ "taskId",
762
+ "patch"
763
+ ])) return void 0;
764
+ return taskId !== void 0 && updatePatch(action.patch) ? {
765
+ requestId: envelope.requestId,
766
+ action
767
+ } : void 0;
768
+ case "set-schedule":
769
+ if (!exactKeys(action, [
770
+ "kind",
771
+ "taskId",
772
+ "patch"
773
+ ])) return void 0;
774
+ return taskId !== void 0 && schedulePatch(action.patch) ? {
775
+ requestId: envelope.requestId,
776
+ action
777
+ } : void 0;
778
+ case "move":
779
+ if (!exactKeys(action, [
780
+ "kind",
781
+ "taskId",
782
+ "status"
783
+ ])) return void 0;
784
+ return taskId !== void 0 && isTaskStatus(action.status) ? {
785
+ requestId: envelope.requestId,
786
+ action
787
+ } : void 0;
788
+ case "delete":
789
+ case "archive":
790
+ case "restore":
791
+ case "run":
792
+ case "rerun":
793
+ if (!exactKeys(action, ["kind", "taskId"])) return void 0;
794
+ return taskId === void 0 ? void 0 : {
795
+ requestId: envelope.requestId,
796
+ action
797
+ };
798
+ default: return;
799
+ }
800
+ }
801
+ //#endregion
802
+ //#region src/host-ledger.ts
803
+ const MAX_REQUEST_CACHE = 256;
804
+ function timeZone() {
805
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
806
+ }
807
+ function cloneTasks(tasks) {
808
+ return JSON.parse(JSON.stringify(tasks));
809
+ }
810
+ function hasOpenExecution(task) {
811
+ return task.executions.some((execution) => execution.endedAt === void 0);
812
+ }
813
+ function processIsAlive(pid) {
814
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
815
+ try {
816
+ process.kill(pid, 0);
817
+ return true;
818
+ } catch (error) {
819
+ return error.code !== "ESRCH";
820
+ }
821
+ }
822
+ function betterExecution(a, b) {
823
+ if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
824
+ if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
825
+ return (b.endedAt ?? b.startedAt) >= (a.endedAt ?? a.startedAt) ? b : a;
826
+ }
827
+ function mergeTask(a, b) {
828
+ const newer = b.updatedAt > a.updatedAt ? b : a;
829
+ const byId = /* @__PURE__ */ new Map();
830
+ for (const entry of [...a.executions, ...b.executions]) {
831
+ const previous = byId.get(entry.id);
832
+ byId.set(entry.id, previous === void 0 ? entry : betterExecution(previous, entry));
833
+ }
834
+ return {
835
+ ...newer,
836
+ executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
837
+ };
838
+ }
839
+ function parseHostTasks(values, now) {
840
+ const rawById = /* @__PURE__ */ new Map();
841
+ for (const value of values) {
842
+ if (typeof value !== "object" || value === null) continue;
843
+ const raw = value;
844
+ if (typeof raw.id === "string") rawById.set(raw.id, raw);
845
+ }
846
+ return parseLedger(JSON.stringify(values)).map((task) => {
847
+ const rawSchedule = rawById.get(task.id)?.schedule;
848
+ if (typeof rawSchedule !== "object" || rawSchedule === null) return task;
849
+ const schedule = rawSchedule;
850
+ if (typeof schedule.cron !== "string" || isValidCron(schedule.cron)) return task;
851
+ return {
852
+ ...task,
853
+ schedule: {
854
+ enabled: false,
855
+ cron: schedule.cron,
856
+ nextRunAt: void 0,
857
+ lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0
858
+ }
859
+ };
860
+ });
861
+ }
862
+ var HostTaskLedger = class {
863
+ now;
864
+ document;
865
+ listeners = /* @__PURE__ */ new Set();
866
+ requestCache = /* @__PURE__ */ new Map();
867
+ lockToken = crypto.randomUUID();
868
+ lockFd;
869
+ file;
870
+ lockFile;
871
+ constructor(dir = join(dshHome(), "task-board"), now = Date.now) {
872
+ this.now = now;
873
+ mkdirSync(dir, { recursive: true });
874
+ this.file = join(dir, "ledger-v2.json");
875
+ this.lockFile = join(dir, "ledger-v2.lock");
876
+ this.lockFd = this.acquireLock();
877
+ try {
878
+ this.document = this.load(dir);
879
+ for (const request of this.document.recentRequests) this.requestCache.set(request.requestId, { fingerprint: request.fingerprint });
880
+ this.repairSchedules(true);
881
+ this.reconcileInterruptedStarts();
882
+ this.commit(false);
883
+ } catch (error) {
884
+ this.dispose();
885
+ throw error;
886
+ }
887
+ }
888
+ state() {
889
+ const { importedSources: _imports, ...scheduler } = this.document.scheduler;
890
+ return {
891
+ revision: this.document.revision,
892
+ tasks: cloneTasks(this.document.tasks),
893
+ scheduler: { ...scheduler }
894
+ };
895
+ }
896
+ subscribe(listener) {
897
+ this.listeners.add(listener);
898
+ return () => {
899
+ this.listeners.delete(listener);
900
+ };
901
+ }
902
+ dispose() {
903
+ const fd = this.lockFd;
904
+ if (fd === void 0) return;
905
+ this.lockFd = void 0;
906
+ closeSync(fd);
907
+ try {
908
+ if (JSON.parse(readFileSync(this.lockFile, "utf8")).token === this.lockToken) unlinkSync(this.lockFile);
909
+ } catch {}
910
+ }
911
+ applyRequest(requestId, action) {
912
+ const fingerprint = createHash("sha256").update(JSON.stringify(action)).digest("hex");
913
+ const cached = this.requestCache.get(requestId);
914
+ if (cached !== void 0) {
915
+ if (cached.fingerprint !== fingerprint) throw new Error("request id was reused with a different action");
916
+ return { state: this.state() };
917
+ }
918
+ this.requestCache.set(requestId, { fingerprint });
919
+ while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value);
920
+ this.syncRecentRequests();
921
+ try {
922
+ return this.apply(action);
923
+ } catch (error) {
924
+ this.requestCache.delete(requestId);
925
+ this.syncRecentRequests();
926
+ throw error;
927
+ }
928
+ }
929
+ openScheduled(taskId, nextRunAt, triggeredAt) {
930
+ const task = this.document.tasks.find((item) => item.id === taskId);
931
+ if (task === void 0 || task.archivedAt !== void 0) return void 0;
932
+ if (task.status === "running" || hasOpenExecution(task)) {
933
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)];
934
+ this.commit();
935
+ return;
936
+ }
937
+ const opened = startExecution(task, triggeredAt, crypto.randomUUID());
938
+ this.document.tasks = this.document.tasks.map((item) => item.id === taskId ? opened.task : item);
939
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)];
940
+ this.commit();
941
+ return opened;
942
+ }
943
+ skipMissed(now) {
944
+ let changed = false;
945
+ this.document.tasks = this.document.tasks.map((task) => {
946
+ const schedule = task.schedule;
947
+ if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) return task;
948
+ changed = true;
949
+ return {
950
+ ...task,
951
+ schedule: {
952
+ ...schedule,
953
+ nextRunAt: nextRunAtMs(schedule.cron, now)
954
+ },
955
+ updatedAt: now
956
+ };
957
+ });
958
+ if (changed) this.commit();
959
+ }
960
+ setScheduler(patch) {
961
+ this.document.scheduler = {
962
+ ...this.document.scheduler,
963
+ ...patch
964
+ };
965
+ this.commit(false);
966
+ }
967
+ attachSession(taskId, executionId, sessionId) {
968
+ const now = this.now();
969
+ this.document.tasks = this.document.tasks.map((task) => task.id !== taskId ? task : {
970
+ ...task,
971
+ updatedAt: now,
972
+ executions: task.executions.map((entry) => entry.id === executionId ? {
973
+ ...entry,
974
+ sessionId
975
+ } : entry)
976
+ });
977
+ this.commit();
978
+ }
979
+ settle(taskId, executionId, outcome, error) {
980
+ this.document.tasks = this.document.tasks.map((task) => task.id === taskId ? settleExecution(task, executionId, outcome, this.now(), error) : task);
981
+ this.commit();
982
+ }
983
+ apply(action) {
984
+ const now = this.now();
985
+ let run;
986
+ switch (action.kind) {
987
+ case "import": {
988
+ const sources = new Set(this.document.scheduler.importedSources ?? []);
989
+ if (sources.has(action.sourceId)) return { state: this.state() };
990
+ const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && !isValidCron(task.schedule.cron)).map((task) => task.id);
991
+ const incoming = parseHostTasks(action.tasks, now);
992
+ const merged = new Map(this.document.tasks.map((task) => [task.id, task]));
993
+ for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id), task) : task);
994
+ this.document.tasks = [...merged.values()];
995
+ this.document.scheduler.importedSources = [...sources, action.sourceId];
996
+ this.document.scheduler.error = invalidScheduleIds.length === 0 ? void 0 : `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}`;
997
+ this.repairSchedules(true, false);
998
+ this.reconcileInterruptedStarts(false);
999
+ break;
1000
+ }
1001
+ case "create": {
1002
+ if (this.document.tasks.some((task) => task.id === action.id)) throw new Error("task id already exists");
1003
+ if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === void 0)) throw new Error("invalid schedule");
1004
+ const result = applyCreateTask(this.document.tasks, action.input, now, action.id);
1005
+ if (result.task === void 0) throw new Error("invalid task");
1006
+ this.document.tasks = [...result.tasks];
1007
+ break;
1008
+ }
1009
+ case "update": {
1010
+ const task = this.document.tasks.find((task) => task.id === action.taskId);
1011
+ if (task === void 0) throw new Error("task not found");
1012
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
1013
+ this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)];
1014
+ break;
1015
+ }
1016
+ case "delete":
1017
+ {
1018
+ const task = this.document.tasks.find((task) => task.id === action.taskId);
1019
+ if (task === void 0) throw new Error("task not found");
1020
+ if (task.status === "running" || hasOpenExecution(task)) throw new Error("running task cannot be deleted");
1021
+ }
1022
+ this.document.tasks = [...applyDeleteTask(this.document.tasks, void 0, action.taskId).tasks];
1023
+ break;
1024
+ case "move": {
1025
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
1026
+ if (task === void 0) throw new Error("task not found");
1027
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
1028
+ if (task.status === "running" || hasOpenExecution(task)) throw new Error("running task cannot be moved");
1029
+ if (!canMoveManually(task.status, action.status)) throw new Error("invalid manual status");
1030
+ this.document.tasks = this.document.tasks.map((item) => item.id === action.taskId ? withStatus(item, action.status, now) : item);
1031
+ break;
1032
+ }
1033
+ case "archive": {
1034
+ const result = applyArchiveTask(this.document.tasks, action.taskId, now);
1035
+ if (!result.archived) throw new Error("task cannot be archived");
1036
+ this.document.tasks = [...result.tasks];
1037
+ break;
1038
+ }
1039
+ case "restore": {
1040
+ const result = applyRestoreTask(this.document.tasks, action.taskId, now);
1041
+ if (!result.archived) throw new Error("task is not archived");
1042
+ this.document.tasks = [...result.tasks];
1043
+ break;
1044
+ }
1045
+ case "set-schedule": {
1046
+ if (this.document.tasks.find((task) => task.id === action.taskId)?.archivedAt !== void 0) throw new Error("archived task is read-only");
1047
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now);
1048
+ if (!result.applied) throw new Error("invalid schedule");
1049
+ this.document.tasks = [...result.tasks];
1050
+ break;
1051
+ }
1052
+ case "rerun":
1053
+ case "run": {
1054
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
1055
+ if (task?.archivedAt !== void 0) throw new Error("archived task is read-only");
1056
+ if (task === void 0 || task.status === "running" || hasOpenExecution(task)) throw new Error("task is already running or missing");
1057
+ run = startExecution(action.kind === "rerun" ? withStatus(task, "todo", now) : task, now, crypto.randomUUID());
1058
+ this.document.tasks = this.document.tasks.map((item) => item.id === task.id ? run.task : item);
1059
+ break;
1060
+ }
1061
+ }
1062
+ this.commit();
1063
+ return {
1064
+ state: this.state(),
1065
+ ...run === void 0 ? {} : { run }
1066
+ };
1067
+ }
1068
+ repairSchedules(skipPast, persist = true) {
1069
+ const now = this.now();
1070
+ let changed = false;
1071
+ this.document.tasks = this.document.tasks.map((task) => {
1072
+ const schedule = task.schedule;
1073
+ if (schedule === void 0 || !schedule.enabled) return task;
1074
+ if (!skipPast && schedule.nextRunAt !== void 0) return task;
1075
+ const next = nextRunAtMs(schedule.cron, now);
1076
+ if (next === void 0) {
1077
+ changed = true;
1078
+ this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
1079
+ return {
1080
+ ...task,
1081
+ schedule: {
1082
+ ...schedule,
1083
+ enabled: false,
1084
+ nextRunAt: void 0
1085
+ },
1086
+ updatedAt: now
1087
+ };
1088
+ }
1089
+ if (schedule.nextRunAt === next) return task;
1090
+ changed = true;
1091
+ return {
1092
+ ...task,
1093
+ schedule: {
1094
+ ...schedule,
1095
+ nextRunAt: next
1096
+ },
1097
+ updatedAt: now
1098
+ };
1099
+ });
1100
+ if (changed && persist) this.commit();
1101
+ }
1102
+ reconcileInterruptedStarts(persist = true) {
1103
+ const now = this.now();
1104
+ let changed = false;
1105
+ this.document.tasks = this.document.tasks.map((task) => {
1106
+ if (task.status !== "running") return task;
1107
+ const execution = task.executions.at(-1);
1108
+ if (execution === void 0 || execution.endedAt !== void 0 || execution.sessionId !== void 0) return task;
1109
+ changed = true;
1110
+ return settleExecution(task, execution.id, "cancelled", now, "host restarted before the execution session was recorded");
1111
+ });
1112
+ if (changed && persist) this.commit();
1113
+ }
1114
+ load(dir) {
1115
+ const existed = existsSync(this.file);
1116
+ try {
1117
+ const parsed = JSON.parse(readFileSync(this.file, "utf8"));
1118
+ if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
1119
+ const tasks = parseHostTasks(parsed.tasks, this.now());
1120
+ const invalidScheduleIds = parsed.tasks.flatMap((value) => {
1121
+ if (typeof value !== "object" || value === null) return [];
1122
+ const row = value;
1123
+ if (typeof row.schedule !== "object" || row.schedule === null) return [];
1124
+ const cron = row.schedule.cron;
1125
+ return typeof cron !== "string" || !isValidCron(cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
1126
+ });
1127
+ return {
1128
+ schemaVersion: 2,
1129
+ revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
1130
+ tasks,
1131
+ scheduler: {
1132
+ timeZone: timeZone(),
1133
+ ledgerId: typeof parsed.scheduler?.ledgerId === "string" && parsed.scheduler.ledgerId !== "" ? parsed.scheduler.ledgerId : crypto.randomUUID(),
1134
+ ...typeof parsed.scheduler?.lastTickAt === "number" ? { lastTickAt: parsed.scheduler.lastTickAt } : {},
1135
+ ...typeof parsed.scheduler?.error === "string" ? { error: parsed.scheduler.error } : {},
1136
+ ...invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}` } : {},
1137
+ ...Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter((x) => typeof x === "string") } : {}
1138
+ },
1139
+ recentRequests: Array.isArray(parsed.recentRequests) ? parsed.recentRequests.flatMap((entry) => {
1140
+ if (typeof entry !== "object" || entry === null) return [];
1141
+ const request = entry;
1142
+ return typeof request.requestId === "string" && request.requestId !== "" && typeof request.fingerprint === "string" ? [{
1143
+ requestId: request.requestId,
1144
+ fingerprint: request.fingerprint
1145
+ }] : [];
1146
+ }).slice(-256) : []
1147
+ };
1148
+ } catch (error) {
1149
+ if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
1150
+ mkdirSync(dir, { recursive: true });
1151
+ return {
1152
+ schemaVersion: 2,
1153
+ revision: 0,
1154
+ tasks: [],
1155
+ scheduler: {
1156
+ timeZone: timeZone(),
1157
+ ledgerId: crypto.randomUUID(),
1158
+ ...existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}
1159
+ },
1160
+ recentRequests: []
1161
+ };
1162
+ }
1163
+ }
1164
+ syncRecentRequests() {
1165
+ this.document.recentRequests = [...this.requestCache].map(([requestId, request]) => ({
1166
+ requestId,
1167
+ fingerprint: request.fingerprint
1168
+ }));
1169
+ }
1170
+ commit(bumpRevision = true) {
1171
+ if (bumpRevision) this.document.revision += 1;
1172
+ mkdirSync(dirname(this.file), { recursive: true });
1173
+ const tmp = `${this.file}.tmp-${process.pid}`;
1174
+ let fd;
1175
+ try {
1176
+ fd = openSync(tmp, "w", 384);
1177
+ writeFileSync(fd, JSON.stringify(this.document, null, 2), { encoding: "utf8" });
1178
+ fsyncSync(fd);
1179
+ closeSync(fd);
1180
+ fd = void 0;
1181
+ try {
1182
+ chmodSync(tmp, 384);
1183
+ } catch {}
1184
+ renameSync(tmp, this.file);
1185
+ try {
1186
+ const dirFd = openSync(dirname(this.file), "r");
1187
+ try {
1188
+ fsyncSync(dirFd);
1189
+ } finally {
1190
+ closeSync(dirFd);
1191
+ }
1192
+ } catch {}
1193
+ } catch (error) {
1194
+ if (fd !== void 0) closeSync(fd);
1195
+ try {
1196
+ unlinkSync(tmp);
1197
+ } catch {}
1198
+ throw error;
1199
+ }
1200
+ this.notify();
1201
+ }
1202
+ notify() {
1203
+ for (const listener of [...this.listeners]) listener();
1204
+ }
1205
+ acquireLock() {
1206
+ for (let attempt = 0; attempt < 2; attempt += 1) try {
1207
+ const fd = openSync(this.lockFile, "wx", 384);
1208
+ writeFileSync(fd, JSON.stringify({
1209
+ pid: process.pid,
1210
+ token: this.lockToken
1211
+ }), { encoding: "utf8" });
1212
+ fsyncSync(fd);
1213
+ try {
1214
+ chmodSync(this.lockFile, 384);
1215
+ } catch {}
1216
+ return fd;
1217
+ } catch (error) {
1218
+ if (error.code !== "EEXIST") throw error;
1219
+ let pid;
1220
+ try {
1221
+ const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
1222
+ if (typeof owner.pid === "number") pid = owner.pid;
1223
+ } catch {
1224
+ throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
1225
+ }
1226
+ if (pid !== void 0 && processIsAlive(pid)) throw new Error(`task-board ledger is already owned by process ${pid}`);
1227
+ try {
1228
+ unlinkSync(this.lockFile);
1229
+ } catch (unlinkError) {
1230
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
1231
+ }
1232
+ }
1233
+ throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`);
1234
+ }
1235
+ };
1236
+ //#endregion
1237
+ //#region src/host-runner.ts
1238
+ function request(payload) {
1239
+ return {
1240
+ rpcId: `task-board-${crypto.randomUUID()}`,
1241
+ payload
1242
+ };
1243
+ }
1244
+ function failure(error) {
1245
+ return /* @__PURE__ */ new Error(`${error.code}: ${error.message}`);
1246
+ }
1247
+ /** A post-create launch failure that still identifies the session to the ledger. */
1248
+ var SessionLaunchError = class extends Error {
1249
+ sessionId;
1250
+ constructor(sessionId, cause) {
1251
+ super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
1252
+ this.sessionId = sessionId;
1253
+ this.name = "SessionLaunchError";
1254
+ }
1255
+ };
1256
+ function isErrorTurnEnd(data) {
1257
+ if (typeof data !== "object" || data === null) return false;
1258
+ const reason = data.reason;
1259
+ return typeof reason === "object" && reason !== null && reason.kind === "error";
1260
+ }
1261
+ var HostExecutionRunner = class {
1262
+ api;
1263
+ constructor(api) {
1264
+ this.api = api;
1265
+ }
1266
+ async launch(task) {
1267
+ if (task.workspaceId !== void 0) {
1268
+ const workspaces = await this.api.workspace.list(request({}));
1269
+ if (!workspaces.result.ok) throw failure(workspaces.result.error);
1270
+ if (!workspaces.result.value.items.some((item) => item.workspaceId === task.workspaceId)) throw new Error(`workspace not found: ${task.workspaceId}`);
1271
+ }
1272
+ if (task.mode !== void 0) {
1273
+ const presets = await this.api.agentPresets.list(request({}));
1274
+ if (!presets.result.ok) throw failure(presets.result.error);
1275
+ const preset = presets.result.value.presets.find((item) => item.id === task.mode);
1276
+ if (preset === void 0) throw new Error(`agent preset not found: ${task.mode}`);
1277
+ if (preset.broken !== void 0) throw new Error(`agent preset is unavailable: ${preset.broken}`);
1278
+ }
1279
+ const created = await this.api.sessions.create(request({
1280
+ ...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
1281
+ ...task.mode === void 0 ? {} : { agentPreset: task.mode }
1282
+ }));
1283
+ if (!created.result.ok) throw failure(created.result.error);
1284
+ const sessionId = created.result.value.sessionId;
1285
+ try {
1286
+ const renamed = await this.api.sessions.rename(request({
1287
+ sessionId,
1288
+ title: task.title
1289
+ }));
1290
+ if (!renamed.result.ok) throw failure(renamed.result.error);
1291
+ if (task.permission !== void 0) {
1292
+ const command = await this.api.sessions.prompt(request({
1293
+ sessionId,
1294
+ mode: "queue",
1295
+ content: [{
1296
+ type: "text",
1297
+ text: `/permission ${task.permission}`
1298
+ }]
1299
+ }));
1300
+ if (!command.result.ok) throw failure(command.result.error);
1301
+ if (command.result.value.command?.kind !== "success") throw new Error("permission command was not acknowledged");
1302
+ }
1303
+ const prompt = await this.api.sessions.prompt(request({
1304
+ sessionId,
1305
+ mode: "queue",
1306
+ content: [{
1307
+ type: "text",
1308
+ text: task.prompt !== "" ? task.prompt : task.title
1309
+ }]
1310
+ }));
1311
+ if (!prompt.result.ok) throw failure(prompt.result.error);
1312
+ } catch (error) {
1313
+ throw new SessionLaunchError(sessionId, error);
1314
+ }
1315
+ return sessionId;
1316
+ }
1317
+ async listRunning() {
1318
+ try {
1319
+ const response = await this.api.sessions.list(request({}));
1320
+ return response.result.ok ? {
1321
+ known: true,
1322
+ count: response.result.value.items.filter((item) => item.running).length
1323
+ } : { known: false };
1324
+ } catch {
1325
+ return { known: false };
1326
+ }
1327
+ }
1328
+ async inspect(sessionId, startedAt = 0) {
1329
+ const sessions = await this.api.sessions.list(request({}));
1330
+ if (!sessions.result.ok) return { outcome: "pending" };
1331
+ const summary = sessions.result.value.items.find((item) => item.sessionId === sessionId);
1332
+ if (summary === void 0) return {
1333
+ outcome: "cancelled",
1334
+ error: "execution session no longer exists"
1335
+ };
1336
+ if (summary.running) return { outcome: "pending" };
1337
+ const events = [];
1338
+ let beforeSeq;
1339
+ let reachedExecutionBoundary = false;
1340
+ for (let page = 0; page < 100; page += 1) {
1341
+ const history = await this.api.sessions.history(request({
1342
+ sessionId: summary.sessionId,
1343
+ maxMessages: 100,
1344
+ ...beforeSeq === void 0 ? {} : { beforeSeq }
1345
+ }));
1346
+ if (!history.result.ok) return { outcome: "pending" };
1347
+ events.push(...history.result.value.events);
1348
+ const oldestTime = history.result.value.events.reduce((oldest, entry) => {
1349
+ const time = entry.event.time;
1350
+ return typeof time !== "number" ? oldest : oldest === void 0 ? time : Math.min(oldest, time);
1351
+ }, void 0);
1352
+ if (!history.result.value.hasMore || oldestTime !== void 0 && oldestTime <= startedAt) {
1353
+ reachedExecutionBoundary = true;
1354
+ break;
1355
+ }
1356
+ const oldestSeq = history.result.value.events.reduce((oldest, entry) => {
1357
+ const seq = entry.event.seq;
1358
+ return typeof seq !== "number" ? oldest : oldest === void 0 ? seq : Math.min(oldest, seq);
1359
+ }, void 0);
1360
+ if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
1361
+ beforeSeq = oldestSeq;
1362
+ }
1363
+ if (!reachedExecutionBoundary) return { outcome: "pending" };
1364
+ const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || typeof entry.event.time === "number" && entry.event.time >= startedAt)).sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0];
1365
+ if (turnEnd === void 0) return { outcome: "pending" };
1366
+ return isErrorTurnEnd(turnEnd.event.data) ? {
1367
+ outcome: "failed",
1368
+ error: "agent turn ended with an error"
1369
+ } : { outcome: "succeeded" };
1370
+ }
1371
+ };
1372
+ //#endregion
1373
+ //#region src/power-inhibitor.ts
1374
+ const RETRY_DELAYS = [
1375
+ 1e3,
1376
+ 2e3,
1377
+ 5e3,
1378
+ 1e4,
1379
+ 3e4
1380
+ ];
1381
+ const DARWIN_STABLE_MS = 3e4;
1382
+ const WINDOWS_HELPER = String.raw`
1383
+ $source = @'
1384
+ using System;
1385
+ using System.Runtime.InteropServices;
1386
+ public static class DshExecutionState {
1387
+ [DllImport("kernel32.dll", SetLastError = true)]
1388
+ public static extern uint SetThreadExecutionState(uint flags);
1389
+ }
1390
+ '@
1391
+ Add-Type -TypeDefinition $source
1392
+ $continuous = [Convert]::ToUInt32('80000000', 16)
1393
+ $systemRequired = [uint32]0x00000001
1394
+ try {
1395
+ $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)
1396
+ if ($result -eq 0) { throw 'SetThreadExecutionState failed' }
1397
+ [Console]::Out.WriteLine('READY')
1398
+ [Console]::Out.Flush()
1399
+ while ([Console]::In.ReadLine() -ne $null) { }
1400
+ } finally {
1401
+ [void][DshExecutionState]::SetThreadExecutionState($continuous)
1402
+ }
1403
+ `;
1404
+ const LINUX_HELPER = String.raw`
1405
+ process.stdout.write('READY\n')
1406
+ process.stdin.resume()
1407
+ `;
1408
+ const LINUX_INHIBIT_PATHS = ["/usr/bin/systemd-inhibit", "/bin/systemd-inhibit"];
1409
+ var PowerInhibitor = class {
1410
+ listeners = /* @__PURE__ */ new Set();
1411
+ enabled = false;
1412
+ reasons = {
1413
+ runningSessions: 0,
1414
+ armedSchedules: 0,
1415
+ sessionStateKnown: false
1416
+ };
1417
+ phase = "disabled";
1418
+ child;
1419
+ retry;
1420
+ retryReset;
1421
+ retryIndex = 0;
1422
+ lastError;
1423
+ stopping = false;
1424
+ platform;
1425
+ pid;
1426
+ env;
1427
+ spawn;
1428
+ spawnSync;
1429
+ exists;
1430
+ execPath;
1431
+ timer;
1432
+ clearTimer;
1433
+ linuxProbe;
1434
+ constructor(options = {}) {
1435
+ this.platform = options.platform ?? process.platform;
1436
+ this.pid = options.pid ?? process.pid;
1437
+ this.env = options.env ?? process.env;
1438
+ this.spawn = options.spawn ?? ((file, args, spawnOptions) => spawn(file, [...args], spawnOptions));
1439
+ this.spawnSync = options.spawnSync ?? ((file, args, spawnOptions) => spawnSync(file, [...args], spawnOptions));
1440
+ this.exists = options.exists ?? existsSync;
1441
+ this.execPath = options.execPath ?? process.execPath;
1442
+ this.timer = options.setTimeout ?? globalThis.setTimeout;
1443
+ this.clearTimer = options.clearTimeout ?? globalThis.clearTimeout;
1444
+ }
1445
+ setEnabled(enabled) {
1446
+ this.enabled = enabled;
1447
+ this.sync();
1448
+ this.emit();
1449
+ }
1450
+ updateReasons(reasons) {
1451
+ this.reasons = reasons;
1452
+ this.sync();
1453
+ this.emit();
1454
+ }
1455
+ subscribe(listener) {
1456
+ this.listeners.add(listener);
1457
+ return () => {
1458
+ this.listeners.delete(listener);
1459
+ };
1460
+ }
1461
+ snapshot() {
1462
+ return {
1463
+ platform: this.platform,
1464
+ phase: this.phase,
1465
+ enabled: this.enabled,
1466
+ ...this.reasons,
1467
+ ...this.lastError === void 0 ? {} : { lastError: this.lastError }
1468
+ };
1469
+ }
1470
+ dispose() {
1471
+ this.enabled = false;
1472
+ this.release();
1473
+ this.phase = "disabled";
1474
+ this.emit();
1475
+ this.listeners.clear();
1476
+ }
1477
+ desired() {
1478
+ return this.enabled && (!this.reasons.sessionStateKnown || this.reasons.runningSessions > 0 || this.reasons.armedSchedules > 0);
1479
+ }
1480
+ sync() {
1481
+ if (!this.enabled) {
1482
+ this.release();
1483
+ this.phase = "disabled";
1484
+ return;
1485
+ }
1486
+ if (this.platform !== "darwin" && this.platform !== "win32" && this.platform !== "linux") {
1487
+ this.release();
1488
+ this.phase = "unsupported";
1489
+ return;
1490
+ }
1491
+ if (this.platform === "linux" && this.linuxSystemdInhibit() === void 0) {
1492
+ this.release();
1493
+ this.phase = "unsupported";
1494
+ return;
1495
+ }
1496
+ if (!this.desired()) {
1497
+ this.release();
1498
+ this.phase = "idle";
1499
+ return;
1500
+ }
1501
+ if (this.child === void 0 && this.retry === void 0) this.acquire();
1502
+ }
1503
+ acquire() {
1504
+ this.phase = "acquiring";
1505
+ this.emit();
1506
+ this.stopping = false;
1507
+ try {
1508
+ const child = this.spawnCommand();
1509
+ this.child = child;
1510
+ let ready = false;
1511
+ let stderr = "";
1512
+ if (this.platform === "darwin") child.once("spawn", () => {
1513
+ ready = true;
1514
+ this.markReady(child);
1515
+ });
1516
+ child.stdout?.on("data", (chunk) => {
1517
+ if (!ready && chunk.toString("utf8").includes("READY")) {
1518
+ ready = true;
1519
+ this.markReady(child);
1520
+ }
1521
+ });
1522
+ child.stderr?.on("data", (chunk) => {
1523
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-2e3);
1524
+ });
1525
+ child.on("error", (error) => {
1526
+ this.fail(error, child);
1527
+ });
1528
+ child.on("exit", (code, signal) => {
1529
+ if (this.child !== child) return;
1530
+ this.child = void 0;
1531
+ if (this.stopping || !this.desired()) return;
1532
+ const detail = stderr.trim();
1533
+ this.fail(/* @__PURE__ */ new Error(`power helper exited (${String(code ?? signal ?? "unknown")})${detail === "" ? "" : `: ${detail}`}`));
1534
+ });
1535
+ } catch (error) {
1536
+ this.fail(error);
1537
+ }
1538
+ }
1539
+ markReady(child) {
1540
+ if (this.child !== child || !this.desired()) return;
1541
+ this.phase = "active";
1542
+ if (this.platform === "darwin") this.retryReset = this.timer(() => {
1543
+ this.retryReset = void 0;
1544
+ if (this.child === child && this.desired()) this.retryIndex = 0;
1545
+ }, DARWIN_STABLE_MS);
1546
+ else this.retryIndex = 0;
1547
+ this.lastError = void 0;
1548
+ this.emit();
1549
+ }
1550
+ fail(error, source) {
1551
+ if (source !== void 0 && this.child !== source) return;
1552
+ this.clearRetryReset();
1553
+ this.lastError = error instanceof Error ? error.message : String(error);
1554
+ this.phase = "error";
1555
+ this.emit();
1556
+ const child = this.child;
1557
+ this.child = void 0;
1558
+ child?.stdin?.end();
1559
+ child?.kill();
1560
+ if (!this.desired() || this.retry !== void 0) return;
1561
+ const delay = RETRY_DELAYS[Math.min(this.retryIndex, RETRY_DELAYS.length - 1)];
1562
+ this.retryIndex += 1;
1563
+ this.retry = this.timer(() => {
1564
+ this.retry = void 0;
1565
+ if (this.desired()) this.acquire();
1566
+ }, delay);
1567
+ }
1568
+ release() {
1569
+ this.clearRetryReset();
1570
+ if (this.retry !== void 0) {
1571
+ this.clearTimer(this.retry);
1572
+ this.retry = void 0;
1573
+ }
1574
+ this.lastError = void 0;
1575
+ const child = this.child;
1576
+ this.child = void 0;
1577
+ if (child === void 0) return;
1578
+ this.stopping = true;
1579
+ if (this.platform === "win32" || this.platform === "linux") {
1580
+ child.stdin?.end();
1581
+ const force = this.timer(() => {
1582
+ if (child.exitCode === null) child.kill();
1583
+ }, 1e3);
1584
+ child.once("exit", () => {
1585
+ this.clearTimer(force);
1586
+ });
1587
+ } else child.kill("SIGTERM");
1588
+ }
1589
+ clearRetryReset() {
1590
+ if (this.retryReset === void 0) return;
1591
+ this.clearTimer(this.retryReset);
1592
+ this.retryReset = void 0;
1593
+ }
1594
+ windowsPowerShell() {
1595
+ const root = this.env.SystemRoot;
1596
+ if (root === void 0 || root.trim() === "") throw new Error("SystemRoot is unavailable");
1597
+ if (!win32.isAbsolute(root)) throw new Error("SystemRoot is not an absolute path");
1598
+ return win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
1599
+ }
1600
+ linuxSystemdInhibit() {
1601
+ if (this.linuxProbe !== void 0) return this.linuxProbe.available ? this.linuxProbe.executable : void 0;
1602
+ const executable = LINUX_INHIBIT_PATHS.find((path) => this.exists(path));
1603
+ if (executable === void 0) {
1604
+ this.linuxProbe = { available: false };
1605
+ return;
1606
+ }
1607
+ const probe = this.spawnSync(executable, ["--list", "--no-pager"], {
1608
+ stdio: "ignore",
1609
+ timeout: 2e3,
1610
+ windowsHide: true
1611
+ });
1612
+ this.linuxProbe = {
1613
+ executable,
1614
+ available: probe.status === 0 && probe.error === void 0
1615
+ };
1616
+ return this.linuxProbe.available ? executable : void 0;
1617
+ }
1618
+ spawnCommand() {
1619
+ if (this.platform === "darwin") return this.spawn("/usr/bin/caffeinate", [
1620
+ "-i",
1621
+ "-w",
1622
+ String(this.pid)
1623
+ ], {
1624
+ shell: false,
1625
+ windowsHide: false,
1626
+ stdio: [
1627
+ "ignore",
1628
+ "ignore",
1629
+ "ignore"
1630
+ ]
1631
+ });
1632
+ if (this.platform === "linux") {
1633
+ const executable = this.linuxSystemdInhibit();
1634
+ if (executable === void 0) throw new Error("systemd-inhibit is unavailable");
1635
+ return this.spawn(executable, [
1636
+ "--what=idle",
1637
+ "--who=DeepSeek Harness task board",
1638
+ "--why=DSH sessions are running or schedules are armed",
1639
+ "--mode=block",
1640
+ "--",
1641
+ this.execPath,
1642
+ "-e",
1643
+ LINUX_HELPER
1644
+ ], {
1645
+ shell: false,
1646
+ windowsHide: false,
1647
+ stdio: [
1648
+ "pipe",
1649
+ "pipe",
1650
+ "pipe"
1651
+ ]
1652
+ });
1653
+ }
1654
+ return this.spawn(this.windowsPowerShell(), [
1655
+ "-NoLogo",
1656
+ "-NoProfile",
1657
+ "-NonInteractive",
1658
+ "-Command",
1659
+ WINDOWS_HELPER
1660
+ ], {
1661
+ shell: false,
1662
+ windowsHide: true,
1663
+ stdio: [
1664
+ "pipe",
1665
+ "pipe",
1666
+ "pipe"
1667
+ ]
1668
+ });
1669
+ }
1670
+ emit() {
1671
+ for (const listener of [...this.listeners]) listener();
1672
+ }
1673
+ };
1674
+ //#endregion
1675
+ //#region src/host-service.ts
1676
+ const SESSION_POLL_MS = 5e3;
1677
+ const SCHEDULE_TICK_MS = 3e4;
1678
+ const RESUME_GAP_MS = 45e3;
1679
+ var TaskBoardHostService = class {
1680
+ ledger;
1681
+ runner;
1682
+ power;
1683
+ listeners = /* @__PURE__ */ new Set();
1684
+ timers = [];
1685
+ lastScheduleTick;
1686
+ disposed = false;
1687
+ pollInFlight = false;
1688
+ tickInFlight = false;
1689
+ active = true;
1690
+ preventIdleSleep = false;
1691
+ now;
1692
+ constructor(api, options = {}) {
1693
+ this.ledger = options.ledger ?? new HostTaskLedger();
1694
+ this.runner = new HostExecutionRunner(api);
1695
+ this.power = options.power ?? new PowerInhibitor();
1696
+ this.now = options.now ?? Date.now;
1697
+ this.ledger.subscribe(() => {
1698
+ this.syncPowerReasons();
1699
+ this.emit();
1700
+ });
1701
+ this.power.subscribe(() => {
1702
+ this.emit();
1703
+ });
1704
+ }
1705
+ start() {
1706
+ if (this.disposed || this.timers.length > 0) return;
1707
+ this.syncPowerReasons();
1708
+ this.timers.push(setInterval(() => {
1709
+ this.schedulePoll();
1710
+ }, SESSION_POLL_MS));
1711
+ this.timers.push(setInterval(() => {
1712
+ this.scheduleTick(false);
1713
+ }, SCHEDULE_TICK_MS));
1714
+ this.schedulePoll();
1715
+ this.scheduleTick(true);
1716
+ }
1717
+ setConfiguration(active, preventIdleSleep) {
1718
+ const resumed = !this.active && active;
1719
+ this.active = active;
1720
+ this.preventIdleSleep = preventIdleSleep;
1721
+ if (resumed) {
1722
+ const current = this.power.snapshot();
1723
+ this.power.updateReasons({
1724
+ runningSessions: current.runningSessions,
1725
+ armedSchedules: this.armedSchedules(),
1726
+ sessionStateKnown: false
1727
+ });
1728
+ }
1729
+ this.power.setEnabled(active && preventIdleSleep);
1730
+ if (resumed) {
1731
+ this.schedulePoll();
1732
+ this.scheduleTick(true);
1733
+ }
1734
+ this.emit();
1735
+ }
1736
+ snapshot() {
1737
+ const state = this.ledger.state();
1738
+ return {
1739
+ schemaVersion: 2,
1740
+ revision: state.revision,
1741
+ tasks: state.tasks,
1742
+ scheduler: state.scheduler,
1743
+ power: this.power.snapshot()
1744
+ };
1745
+ }
1746
+ subscribe(listener) {
1747
+ this.listeners.add(listener);
1748
+ return () => {
1749
+ this.listeners.delete(listener);
1750
+ };
1751
+ }
1752
+ apply(requestId, action) {
1753
+ if (!this.active) throw new Error("task board is disabled");
1754
+ const result = this.ledger.applyRequest(requestId, action);
1755
+ if (result.run !== void 0) this.scheduleLaunch(result.run);
1756
+ return {
1757
+ schemaVersion: 2,
1758
+ revision: result.state.revision,
1759
+ tasks: result.state.tasks,
1760
+ scheduler: result.state.scheduler,
1761
+ power: this.power.snapshot()
1762
+ };
1763
+ }
1764
+ dispose() {
1765
+ this.disposed = true;
1766
+ for (const timer of this.timers.splice(0)) clearInterval(timer);
1767
+ this.power.dispose();
1768
+ this.ledger.dispose();
1769
+ this.listeners.clear();
1770
+ }
1771
+ async launch(opened) {
1772
+ try {
1773
+ const sessionId = await this.runner.launch(opened.task);
1774
+ this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
1775
+ } catch (error) {
1776
+ if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
1777
+ this.ledger.settle(opened.task.id, opened.execution.id, "failed", error instanceof Error ? error.message : String(error));
1778
+ }
1779
+ }
1780
+ async pollSessions() {
1781
+ if (this.disposed) return;
1782
+ if (!this.active && !this.hasOpenExecutions()) return;
1783
+ const running = await this.runner.listRunning();
1784
+ const previous = this.power.snapshot();
1785
+ this.power.updateReasons({
1786
+ runningSessions: running.known ? running.count : previous.runningSessions,
1787
+ armedSchedules: this.armedSchedules(),
1788
+ sessionStateKnown: running.known
1789
+ });
1790
+ if (running.known) await this.reconcileExecutions();
1791
+ this.emit();
1792
+ }
1793
+ async reconcileExecutions() {
1794
+ for (const task of this.ledger.state().tasks) for (const execution of task.executions) {
1795
+ if (execution.sessionId === void 0 || execution.endedAt !== void 0) continue;
1796
+ try {
1797
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt);
1798
+ if (result.outcome === "pending") continue;
1799
+ this.ledger.settle(task.id, execution.id, result.outcome, "error" in result ? result.error : void 0);
1800
+ } catch {}
1801
+ }
1802
+ }
1803
+ async tickSchedule(first) {
1804
+ if (this.disposed || !this.active) return;
1805
+ const now = this.now();
1806
+ const recovered = first || this.lastScheduleTick !== void 0 && now - this.lastScheduleTick > RESUME_GAP_MS;
1807
+ this.lastScheduleTick = now;
1808
+ this.ledger.setScheduler({ lastTickAt: now });
1809
+ if (recovered) {
1810
+ this.ledger.skipMissed(now);
1811
+ return;
1812
+ }
1813
+ for (const task of this.ledger.state().tasks) {
1814
+ if (task.archivedAt !== void 0) continue;
1815
+ const schedule = task.schedule;
1816
+ if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
1817
+ const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
1818
+ const opened = this.ledger.openScheduled(task.id, next, now);
1819
+ if (opened !== void 0) this.scheduleLaunch(opened);
1820
+ }
1821
+ }
1822
+ armedSchedules() {
1823
+ return this.ledger.state().tasks.filter((task) => task.archivedAt === void 0 && task.schedule?.enabled === true).length;
1824
+ }
1825
+ hasOpenExecutions() {
1826
+ return this.ledger.state().tasks.some((task) => task.executions.some((execution) => execution.endedAt === void 0));
1827
+ }
1828
+ scheduleLaunch(opened) {
1829
+ this.launch(opened).catch((error) => {
1830
+ console.error("[dsh-task-board] execution launch settlement failed", error);
1831
+ });
1832
+ }
1833
+ schedulePoll() {
1834
+ if (this.pollInFlight || this.disposed) return;
1835
+ this.pollInFlight = true;
1836
+ this.pollSessions().catch((error) => {
1837
+ console.error("[dsh-task-board] session polling failed", error);
1838
+ }).finally(() => {
1839
+ this.pollInFlight = false;
1840
+ });
1841
+ }
1842
+ scheduleTick(first) {
1843
+ if (this.tickInFlight || this.disposed) return;
1844
+ this.tickInFlight = true;
1845
+ this.tickSchedule(first).catch((error) => {
1846
+ console.error("[dsh-task-board] scheduler tick failed", error);
1847
+ }).finally(() => {
1848
+ this.tickInFlight = false;
1849
+ });
1850
+ }
1851
+ syncPowerReasons() {
1852
+ const current = this.power.snapshot();
1853
+ this.power.updateReasons({
1854
+ runningSessions: current.runningSessions,
1855
+ armedSchedules: this.armedSchedules(),
1856
+ sessionStateKnown: current.sessionStateKnown
1857
+ });
1858
+ this.power.setEnabled(this.active && this.preventIdleSleep);
1859
+ }
1860
+ emit() {
1861
+ for (const listener of [...this.listeners]) listener();
1862
+ }
1863
+ };
1864
+ //#endregion
1865
+ //#region src/loopback.ts
1866
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
1867
+ function isIPv4Loopback(v4) {
1868
+ const parts = v4.split(".");
1869
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1870
+ }
1871
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
1872
+ function isLoopbackAddress(address) {
1873
+ if (address === void 0) return false;
1874
+ const normalized = address.toLowerCase();
1875
+ if (normalized === "::1") return true;
1876
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
1877
+ return isIPv4Loopback(normalized);
1878
+ }
1879
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
1880
+ function isLoopbackHostname(hostname) {
1881
+ if (hostname === "localhost" || hostname === "[::1]") return true;
1882
+ return isIPv4Loopback(hostname);
1883
+ }
1884
+ /**
1885
+ * Request-level trust fence: a loopback socket address AND a loopback Host
1886
+ * header, plus browser same-origin markers. The socket address is
1887
+ * authoritative; X-Forwarded-For is never trusted.
1888
+ */
1889
+ function isLoopbackRequest(request) {
1890
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
1891
+ const host = request.headers.host;
1892
+ if (typeof host !== "string") return false;
1893
+ let hostUrl;
1894
+ try {
1895
+ hostUrl = new URL("http://" + host);
1896
+ } catch {
1897
+ return false;
1898
+ }
1899
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
1900
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
1901
+ const origin = request.headers.origin;
1902
+ if (origin === void 0) return true;
1903
+ try {
1904
+ return new URL(origin).host === hostUrl.host;
1905
+ } catch {
1906
+ return false;
1907
+ }
1908
+ }
1909
+ //#endregion
1910
+ //#region src/host-routes.ts
1911
+ const ACTION_LIMIT = 64 * 1024;
1912
+ const IMPORT_LIMIT = 2 * 1024 * 1024;
1913
+ const HEARTBEAT_MS = 15e3;
1914
+ /** Header replaced by an authenticated same-host reverse proxy. */
1915
+ const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
1916
+ function json(res, status, body) {
1917
+ res.writeHead(status, {
1918
+ "content-type": "application/json; charset=utf-8",
1919
+ "cache-control": "no-store"
1920
+ });
1921
+ res.end(JSON.stringify(body));
1922
+ }
1923
+ function parseAuthority(authority) {
1924
+ if (authority.trim() !== authority) return void 0;
1925
+ const match = authority.startsWith("[") ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority);
1926
+ if (match === null) return void 0;
1927
+ try {
1928
+ const url = new URL(`http://${authority}`);
1929
+ if (url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") return void 0;
1930
+ const rawPort = match[1];
1931
+ if (rawPort !== void 0 && (String(Number(rawPort)) !== rawPort || Number(rawPort) > 65535)) return void 0;
1932
+ return {
1933
+ canonical: url.hostname.toLowerCase() + (rawPort === void 0 ? "" : `:${rawPort}`),
1934
+ url
1935
+ };
1936
+ } catch {
1937
+ return;
1938
+ }
1939
+ }
1940
+ function resolveAccess(access) {
1941
+ const trustedProxyHosts = /* @__PURE__ */ new Set();
1942
+ for (const authority of access.trustedProxyHosts ?? []) {
1943
+ const parsed = parseAuthority(authority);
1944
+ if (parsed === void 0 || parsed.canonical !== authority.toLowerCase()) throw new Error(`task-board: trustedProxyHosts entry ${JSON.stringify(authority)} is not a canonical host[:port] authority`);
1945
+ trustedProxyHosts.add(parsed.canonical);
1946
+ }
1947
+ if (trustedProxyHosts.size > 0 && (access.proxyToken === void 0 || access.proxyToken === "")) throw new Error("task-board: authenticated proxy hosts require a non-empty proxy token");
1948
+ return {
1949
+ trustedProxyHosts,
1950
+ ...access.proxyToken === void 0 ? {} : { proxyToken: access.proxyToken }
1951
+ };
1952
+ }
1953
+ function browserSameOriginMarker(req) {
1954
+ return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
1955
+ }
1956
+ function sameAuthority(req, host) {
1957
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
1958
+ const origin = req.headers.origin;
1959
+ if (origin === void 0) return req.headers["sec-fetch-site"] === "same-origin";
1960
+ try {
1961
+ return new URL(origin).host === host.host;
1962
+ } catch {
1963
+ return false;
1964
+ }
1965
+ }
1966
+ function matchesToken(candidate, expected) {
1967
+ if (typeof candidate !== "string" || expected === void 0 || candidate === "" || expected === "") return false;
1968
+ const actual = Buffer.from(candidate);
1969
+ const wanted = Buffer.from(expected);
1970
+ return actual.length === wanted.length && timingSafeEqual(actual, wanted);
1971
+ }
1972
+ /**
1973
+ * Task-board route fence. Direct desktop access uses the repository-wide
1974
+ * loopback socket + Host guard and additionally requires a browser same-origin
1975
+ * marker, so a bare local curl cannot exercise the agent control plane.
1976
+ * Authenticated proxies must be explicitly allowlisted and replace the
1977
+ * internal token header after their own authentication step.
1978
+ */
1979
+ function isTrustedTaskBoardRequest(req, access) {
1980
+ if (!browserSameOriginMarker(req)) return false;
1981
+ if (isLoopbackRequest(req)) return true;
1982
+ if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
1983
+ const host = req.headers.host;
1984
+ if (typeof host !== "string") return false;
1985
+ const parsed = parseAuthority(host);
1986
+ if (parsed === void 0 || parsed.canonical !== host.toLowerCase()) return false;
1987
+ if (!access.trustedProxyHosts.has(parsed.canonical) || !sameAuthority(req, parsed.url)) return false;
1988
+ return matchesToken(req.headers[TASK_BOARD_PROXY_TOKEN_HEADER], access.proxyToken);
1989
+ }
1990
+ async function readBody(req) {
1991
+ const chunks = [];
1992
+ let size = 0;
1993
+ for await (const chunk of req) {
1994
+ const buffer = chunk;
1995
+ size += buffer.length;
1996
+ if (size > IMPORT_LIMIT) throw new Error("body-too-large");
1997
+ chunks.push(buffer);
1998
+ }
1999
+ const raw = Buffer.concat(chunks).toString("utf8");
2000
+ return {
2001
+ raw,
2002
+ value: JSON.parse(raw)
2003
+ };
2004
+ }
2005
+ function makeTaskBoardRoutes(service, access = {}) {
2006
+ const resolvedAccess = resolveAccess(access);
2007
+ const guard = (req, res) => {
2008
+ if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true;
2009
+ json(res, 403, {
2010
+ ok: false,
2011
+ error: "forbidden"
2012
+ });
2013
+ return false;
2014
+ };
2015
+ return [
2016
+ {
2017
+ kind: "exact",
2018
+ path: `${TASK_BOARD_API_PREFIX}/state`,
2019
+ handler: (req, res) => {
2020
+ if (req.method !== "GET") return json(res, 405, {
2021
+ ok: false,
2022
+ error: "method-not-allowed"
2023
+ });
2024
+ if (!guard(req, res)) return;
2025
+ json(res, 200, service.snapshot());
2026
+ }
2027
+ },
2028
+ {
2029
+ kind: "exact",
2030
+ path: `${TASK_BOARD_API_PREFIX}/action`,
2031
+ handler: async (req, res) => {
2032
+ if (req.method !== "POST") return json(res, 405, {
2033
+ ok: false,
2034
+ error: "method-not-allowed"
2035
+ });
2036
+ if (!guard(req, res)) return;
2037
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return json(res, 415, {
2038
+ ok: false,
2039
+ error: "json-required"
2040
+ });
2041
+ try {
2042
+ const body = await readBody(req);
2043
+ const parsed = parseActionEnvelope(body.value);
2044
+ if (parsed === void 0) return json(res, 400, {
2045
+ ok: false,
2046
+ error: "invalid-action"
2047
+ });
2048
+ if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return json(res, 413, {
2049
+ ok: false,
2050
+ error: "body-too-large"
2051
+ });
2052
+ json(res, 200, service.apply(parsed.requestId, parsed.action));
2053
+ } catch (error) {
2054
+ const message = error instanceof Error ? error.message : String(error);
2055
+ json(res, message === "body-too-large" ? 413 : 400, {
2056
+ ok: false,
2057
+ error: message
2058
+ });
2059
+ }
2060
+ }
2061
+ },
2062
+ {
2063
+ kind: "exact",
2064
+ path: `${TASK_BOARD_API_PREFIX}/events`,
2065
+ handler: (req, res) => {
2066
+ if (req.method !== "GET") {
2067
+ res.writeHead(405);
2068
+ res.end();
2069
+ return;
2070
+ }
2071
+ if (!guard(req, res)) return;
2072
+ res.writeHead(200, {
2073
+ "content-type": "text/event-stream; charset=utf-8",
2074
+ "cache-control": "no-cache",
2075
+ connection: "keep-alive"
2076
+ });
2077
+ const push = () => {
2078
+ const snapshot = service.snapshot();
2079
+ res.write(`data: ${JSON.stringify({
2080
+ revision: snapshot.revision,
2081
+ scheduler: snapshot.scheduler,
2082
+ power: snapshot.power
2083
+ })}\n\n`);
2084
+ };
2085
+ const unsubscribe = service.subscribe(push);
2086
+ const heartbeat = setInterval(() => {
2087
+ res.write(": ping\n\n");
2088
+ }, HEARTBEAT_MS);
2089
+ const close = () => {
2090
+ clearInterval(heartbeat);
2091
+ unsubscribe();
2092
+ };
2093
+ req.once("close", close);
2094
+ res.once("close", close);
2095
+ push();
2096
+ }
2097
+ }
2098
+ ];
2099
+ }
2100
+ //#endregion
2101
+ //#region src/mount-once.ts
2102
+ /**
2103
+ * Host single-instance guard shared by the plugin family. The family bundle
2104
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
2105
+ * the loader accepts a standalone install of the same package side by side;
2106
+ * without this guard the second instance would still re-register the same
2107
+ * webserver routes, tools, settings namespaces, and system-prompt sections
2108
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
2109
+ * lifetime of the first instance (the browser half is already deduped by
2110
+ * package name in the client module host).
2111
+ *
2112
+ * The registry rides a global symbol so two module instances of the same
2113
+ * package (npm copy vs repository link) still share one verdict. cordis
2114
+ * `ctx.effect` runs its callback immediately and treats the callback's
2115
+ * return value as the fiber disposer, so the unmarker is returned, not run.
2116
+ */
2117
+ const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
2118
+ function mountedSet() {
2119
+ const registry = globalThis;
2120
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
2121
+ }
2122
+ /**
2123
+ * Wrap a cordis plugin apply so the package runs at most once per process.
2124
+ * The first mount registers normally and unmarks when its fiber disposes;
2125
+ * any later mount of the same package name is a no-op.
2126
+ * @param packageName - npm package identity shared by every install source.
2127
+ * @param fn - the original plugin apply.
2128
+ * @returns an apply of the same shape.
2129
+ */
2130
+ function mountOnce(packageName, fn) {
2131
+ return ((...args) => {
2132
+ const mounted = mountedSet();
2133
+ if (mounted.has(packageName)) return;
2134
+ mounted.add(packageName);
2135
+ args[0]?.effect?.(() => () => {
2136
+ mounted.delete(packageName);
2137
+ });
2138
+ return fn(...args);
2139
+ });
2140
+ }
2141
+ //#endregion
3
2142
  //#region src/index.ts
4
2143
  /** Order of the announcement section within the tool-guidance band. */
5
2144
  const SECTION_ORDER = 200;
6
- const inject = ["systemPrompt"];
2145
+ /** Default environment variable holding the authenticated proxy token. */
2146
+ const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
2147
+ const inject = [
2148
+ "systemPrompt",
2149
+ "apiProxy",
2150
+ "webServer"
2151
+ ];
7
2152
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
8
- 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 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。";
2153
+ 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 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。";
9
2154
  /**
10
2155
  * Settings namespace of the board's announcement capability — the section the
11
2156
  * web settings surface edits. Spelled here rather than imported: the browser
@@ -14,8 +2159,24 @@ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI
14
2159
  const TASK_BOARD_SETTINGS_NAMESPACE = settingsNamespace("task-board");
15
2160
  const Config = z.object({
16
2161
  announceToAgent: z.boolean().default(true),
17
- enabled: z.boolean().default(true)
2162
+ enabled: z.boolean().default(true),
2163
+ preventIdleSleep: z.boolean().default(false),
2164
+ trustedProxyHosts: z.array(z.string()).default([]),
2165
+ proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV)
18
2166
  });
2167
+ /** Resolve proxy access without ever placing the token value in plugin config. */
2168
+ function resolveProxyAccess(config, env = process.env) {
2169
+ const trustedProxyHosts = config?.trustedProxyHosts ?? [];
2170
+ if (trustedProxyHosts.length === 0) return { trustedProxyHosts };
2171
+ const proxyTokenEnv = config?.proxyTokenEnv ?? "DSH_TASK_BOARD_PROXY_TOKEN";
2172
+ if (proxyTokenEnv.trim() === "") throw new Error("task-board: proxyTokenEnv must not be empty");
2173
+ const proxyToken = env[proxyTokenEnv];
2174
+ if (proxyToken === void 0 || proxyToken === "") throw new Error(`task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`);
2175
+ return {
2176
+ trustedProxyHosts,
2177
+ proxyToken
2178
+ };
2179
+ }
19
2180
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
20
2181
  const DEFAULT_ANNOUNCE = true;
21
2182
  /**
@@ -26,7 +2187,25 @@ const DEFAULT_ANNOUNCE = true;
26
2187
  * @param ctx - the plugin context (systemPrompt injected).
27
2188
  * @param config - resolved plugin config (schema defaults applied by the loader).
28
2189
  */
29
- function apply(ctx, config) {
2190
+ const apply = mountOnce("@linxin666/dsh-client-ui-task-board", applyImpl);
2191
+ function applyImpl(ctx, config) {
2192
+ const host = new TaskBoardHostService(ctx.apiProxy);
2193
+ host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false);
2194
+ host.start();
2195
+ ctx.effect(() => {
2196
+ const disposers = [];
2197
+ try {
2198
+ for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route));
2199
+ } catch (error) {
2200
+ for (const dispose of disposers) dispose();
2201
+ host.dispose();
2202
+ throw error;
2203
+ }
2204
+ return () => {
2205
+ for (const dispose of disposers) dispose();
2206
+ host.dispose();
2207
+ };
2208
+ }, "task-board: host ledger, scheduler, and routes");
30
2209
  let current = () => config ?? {};
31
2210
  let disposeSection;
32
2211
  const sync = () => {
@@ -34,7 +2213,9 @@ function apply(ctx, config) {
34
2213
  disposeSection();
35
2214
  disposeSection = void 0;
36
2215
  }
37
- if ((current().enabled ?? true) === false) return;
2216
+ const active = current().enabled ?? true;
2217
+ host.setConfiguration(active, current().preventIdleSleep ?? false);
2218
+ if (!active) return;
38
2219
  if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return;
39
2220
  disposeSection = ctx.systemPrompt.section({
40
2221
  name: "plugin:task-board",
@@ -51,4 +2232,4 @@ function apply(ctx, config) {
51
2232
  sync();
52
2233
  }
53
2234
  //#endregion
54
- export { Config, TASK_BOARD_GUIDANCE, TASK_BOARD_SETTINGS_NAMESPACE, apply, inject };
2235
+ export { Config, DEFAULT_PROXY_TOKEN_ENV, TASK_BOARD_GUIDANCE, TASK_BOARD_SETTINGS_NAMESPACE, apply, inject, resolveProxyAccess };