@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.1

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