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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +82 -104
  3. package/README.zh.md +82 -104
  4. package/lib/client.js +574 -581
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2144 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -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/core/controller.d.ts +40 -15
  13. package/lib/types/core/schedule.d.ts +4 -2
  14. package/lib/types/core/tasks.d.ts +6 -6
  15. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  16. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  17. package/lib/types/dsh-home.d.ts +16 -0
  18. package/lib/types/host-ledger.d.ts +42 -0
  19. package/lib/types/host-routes.d.ts +24 -0
  20. package/lib/types/host-runner.d.ts +30 -0
  21. package/lib/types/host-service.d.ts +41 -0
  22. package/lib/types/index.d.ts +17 -9
  23. package/lib/types/loopback.d.ts +24 -0
  24. package/lib/types/power-inhibitor.d.ts +73 -0
  25. package/lib/types/protocol.d.ts +72 -0
  26. package/package.json +12 -9
  27. package/src/client/PluginSettingsCard.tsx +10 -0
  28. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  29. package/src/client/board/NewTaskModal.tsx +9 -6
  30. package/src/client/board/TaskBoard.tsx +21 -4
  31. package/src/client/board/TaskCard.tsx +24 -8
  32. package/src/client/board/TaskDetail.tsx +83 -50
  33. package/src/client/host-api.ts +95 -0
  34. package/src/client/index.ts +9 -78
  35. package/src/client/locales.ts +24 -4
  36. package/src/core/controller.ts +205 -24
  37. package/src/core/schedule.ts +20 -3
  38. package/src/core/store.ts +4 -8
  39. package/src/core/tasks.ts +6 -6
  40. package/src/core/use-cases/task-archive.ts +14 -5
  41. package/src/core/use-cases/task-schedule.ts +6 -5
  42. package/src/dsh-home.ts +36 -0
  43. package/src/host-ledger.ts +478 -0
  44. package/src/host-routes.ts +188 -0
  45. package/src/host-runner.ts +134 -0
  46. package/src/host-service.ts +209 -0
  47. package/src/index.ts +54 -11
  48. package/src/loopback.ts +63 -0
  49. package/src/power-inhibitor.ts +321 -0
  50. package/src/protocol.ts +213 -0
  51. package/lib/types/core/scheduler.d.ts +0 -70
  52. package/src/core/scheduler.ts +0 -161
package/lib/client.js CHANGED
@@ -172,7 +172,8 @@ window.__ModuleLoader__.load({
172
172
  /**
173
173
  * Archive one task: only settled statuses (done/failed) can be archived;
174
174
  * a running or not-yet-settled task stays on the board (its runner still
175
- * owns its lifecycle). Already-archived tasks are a no-op.
175
+ * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
176
+ * are a no-op.
176
177
  */
177
178
  function applyArchiveTask(tasks, id, now) {
178
179
  let applied = false;
@@ -181,8 +182,14 @@ window.__ModuleLoader__.load({
181
182
  if (task.id !== id || task.archivedAt !== void 0) return task;
182
183
  if (!ARCHIVABLE_STATUSES.includes(task.status)) return task;
183
184
  applied = true;
185
+ const schedule = task.schedule === void 0 ? void 0 : {
186
+ ...task.schedule,
187
+ enabled: false,
188
+ nextRunAt: void 0
189
+ };
184
190
  return {
185
191
  ...task,
192
+ ...schedule === void 0 ? {} : { schedule },
186
193
  archivedAt: now,
187
194
  updatedAt: now
188
195
  };
@@ -249,20 +256,46 @@ window.__ModuleLoader__.load({
249
256
  /**
250
257
  * Compute the next matching instant after `fromMs` (ms epoch), in local time,
251
258
  * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
252
- * of the matching minute's start, or undefined when nothing matches within
253
- * 366 days (e.g. `0 0 30 2 *`).
259
+ * of the matching minute's start, or undefined when the calendar constraint
260
+ * can never match (for example `0 0 30 2 *`). The five-year horizon includes
261
+ * a full leap cycle, so a valid February 29 schedule remains reachable from
262
+ * every non-leap year.
254
263
  */
255
264
  function nextRunAtMs(expr, fromMs) {
256
265
  const schedule = parseCron(expr);
257
266
  if (schedule === null) return void 0;
267
+ if (!hasPossibleCalendarDay(schedule)) return void 0;
258
268
  const from = new Date(fromMs);
259
269
  const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
260
- const limitMs = fromMs + 366 * 24 * 60 * 60 * 1e3;
270
+ const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
261
271
  while (scan.getTime() <= limitMs) {
262
272
  if (matches(schedule, scan)) return scan.getTime();
263
273
  scan.setMinutes(scan.getMinutes() + 1);
264
274
  }
265
275
  }
276
+ /** Reject impossible month/day pairs without spending the multi-year scan. */
277
+ function hasPossibleCalendarDay(schedule) {
278
+ if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
279
+ const maximumDay = /* @__PURE__ */ new Map([
280
+ [1, 31],
281
+ [2, 29],
282
+ [3, 31],
283
+ [4, 30],
284
+ [5, 31],
285
+ [6, 30],
286
+ [7, 31],
287
+ [8, 31],
288
+ [9, 30],
289
+ [10, 31],
290
+ [11, 30],
291
+ [12, 31]
292
+ ]);
293
+ for (const month of schedule.months) {
294
+ const maximum = maximumDay.get(month) ?? 0;
295
+ if ([...schedule.days].some((day) => day <= maximum)) return true;
296
+ }
297
+ return false;
298
+ }
266
299
  /** Parse one comma-list field into the match set. */
267
300
  function parseField(field, min, max, out) {
268
301
  if (field === "*") {
@@ -366,9 +399,9 @@ window.__ModuleLoader__.load({
366
399
  * the core cron parser (schedule.ts) and the withSchedule transition.
367
400
  */
368
401
  /**
369
- * Set a task's schedule rule. A blank or invalid cron is rejected (state
370
- * untouched); an enabled rule computes the next run instant immediately, a
371
- * disabled or invalid one carries no next-run instant.
402
+ * Set an on-board task's schedule rule. A blank or invalid cron, or an
403
+ * archived task, is rejected (state untouched); an enabled rule computes the
404
+ * next run instant immediately, a disabled one carries no next-run instant.
372
405
  * @param tasks - current ledger.
373
406
  * @param id - the task to schedule.
374
407
  * @param patch - rule fields to change (absent fields keep their current value).
@@ -376,7 +409,7 @@ window.__ModuleLoader__.load({
376
409
  */
377
410
  function applySetSchedule(tasks, id, patch, now) {
378
411
  const task = tasks.find((candidate) => candidate.id === id);
379
- if (task === void 0) return {
412
+ if (task === void 0 || task.archivedAt !== void 0) return {
380
413
  tasks,
381
414
  applied: false
382
415
  };
@@ -388,6 +421,10 @@ window.__ModuleLoader__.load({
388
421
  };
389
422
  const enabled = patch.enabled ?? current?.enabled ?? false;
390
423
  const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
424
+ if (enabled && nextRunAt === void 0) return {
425
+ tasks,
426
+ applied: false
427
+ };
391
428
  return {
392
429
  tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
393
430
  enabled,
@@ -408,7 +445,7 @@ window.__ModuleLoader__.load({
408
445
  * @param now - clock instant (ms epoch).
409
446
  */
410
447
  function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
411
- return tasks.map((task) => task.id === id && task.schedule !== void 0 ? withSchedule(task, {
448
+ return tasks.map((task) => task.id === id && task.archivedAt === void 0 && task.schedule !== void 0 ? withSchedule(task, {
412
449
  nextRunAt,
413
450
  lastTriggeredAt
414
451
  }, now) : task);
@@ -476,6 +513,9 @@ window.__ModuleLoader__.load({
476
513
  function currentOf(sessions) {
477
514
  return sessions.list.getSnapshot().current;
478
515
  }
516
+ function messageOf(error) {
517
+ return error instanceof Error ? error.message : String(error);
518
+ }
479
519
  /**
480
520
  * Board controller (see module doc). All mutations bump the snapshot and
481
521
  * persist through the store; UI and DOM mounts subscribe and re-render.
@@ -494,6 +534,12 @@ window.__ModuleLoader__.load({
494
534
  disposers = [];
495
535
  now;
496
536
  uuid;
537
+ pendingTaskIds = /* @__PURE__ */ new Set();
538
+ taskQueues = /* @__PURE__ */ new Map();
539
+ transportError;
540
+ hostState;
541
+ remoteSubscribed = false;
542
+ remoteInitialization;
497
543
  /** @param deps - store, execution service, and the sessions navigation face. */
498
544
  constructor(deps) {
499
545
  this.deps = deps;
@@ -503,11 +549,12 @@ window.__ModuleLoader__.load({
503
549
  /** Load the persisted ledger and start the navigation/status subscriptions. */
504
550
  start() {
505
551
  this.tasks = this.deps.store.load();
506
- this.reconcileRunningTasks();
507
- const unsubscribeExternal = this.deps.store.subscribeExternal?.(() => {
552
+ if (this.deps.transport !== void 0) this.initializeRemote();
553
+ else this.reconcileRunningTasks();
554
+ const unsubscribeExternal = this.deps.transport === void 0 ? this.deps.store.subscribeExternal?.(() => {
508
555
  this.tasks = this.deps.store.load();
509
556
  this.notify();
510
- });
557
+ }) : void 0;
511
558
  if (unsubscribeExternal !== void 0) this.disposers.push(unsubscribeExternal);
512
559
  this.disposers.push(this.deps.sessions.list.subscribe(() => {
513
560
  this.onSessionsChanged();
@@ -527,7 +574,10 @@ window.__ModuleLoader__.load({
527
574
  boardOpen: this.boardOpen,
528
575
  archiveView: this.archiveView,
529
576
  selectedTaskId: this.selectedTaskId,
530
- executionOptions: this.executionOptions
577
+ executionOptions: this.executionOptions,
578
+ pendingTaskIds: [...this.pendingTaskIds],
579
+ ...this.transportError === void 0 ? {} : { transportError: this.transportError },
580
+ ...this.hostState === void 0 ? {} : { host: this.hostState }
531
581
  };
532
582
  }
533
583
  subscribe(fn) {
@@ -536,6 +586,14 @@ window.__ModuleLoader__.load({
536
586
  this.listeners.delete(fn);
537
587
  };
538
588
  }
589
+ /** Whether production mutations are confirmed by the Host transport. */
590
+ isHostBacked() {
591
+ return this.deps.transport !== void 0;
592
+ }
593
+ /** Retry initial migration/state synchronization after an explicit Host error. */
594
+ async retryHostSync() {
595
+ return await this.initializeRemote();
596
+ }
539
597
  openBoard() {
540
598
  if (this.boardOpen) return;
541
599
  this.lastCurrent = currentOf(this.deps.sessions);
@@ -575,13 +633,33 @@ window.__ModuleLoader__.load({
575
633
  this.notify();
576
634
  }
577
635
  createTask(input) {
578
- const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), this.uuid());
636
+ const id = this.uuid();
637
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id);
579
638
  if (task === void 0) return void 0;
580
639
  this.tasks = [...tasks];
581
640
  this.persistAndNotify();
582
641
  return task;
583
642
  }
643
+ /** Create through the Host and expose the task only after confirmation. */
644
+ async createTaskConfirmed(input) {
645
+ if (this.deps.transport === void 0) return this.createTask(input);
646
+ const id = this.uuid();
647
+ if (applyCreateTask(this.tasks, input, this.now(), id).task === void 0) return void 0;
648
+ return await this.commitRemote({
649
+ kind: "create",
650
+ id,
651
+ input
652
+ }, id) ? this.tasks.find((task) => task.id === id) : void 0;
653
+ }
584
654
  updateTask(id, patch) {
655
+ if (this.deps.transport !== void 0) {
656
+ this.commitRemote({
657
+ kind: "update",
658
+ taskId: id,
659
+ patch
660
+ }, id);
661
+ return;
662
+ }
585
663
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())];
586
664
  this.persistAndNotify();
587
665
  }
@@ -597,10 +675,25 @@ window.__ModuleLoader__.load({
597
675
  this.notify();
598
676
  }
599
677
  moveTask(id, status) {
678
+ if (this.deps.transport !== void 0) {
679
+ this.commitRemote({
680
+ kind: "move",
681
+ taskId: id,
682
+ status
683
+ }, id);
684
+ return;
685
+ }
600
686
  this.tasks = this.tasks.map((task) => task.id === id ? withStatus(task, status, this.now()) : task);
601
687
  this.persistAndNotify();
602
688
  }
603
689
  deleteTask(id) {
690
+ if (this.deps.transport !== void 0) {
691
+ this.commitRemote({
692
+ kind: "delete",
693
+ taskId: id
694
+ }, id);
695
+ return;
696
+ }
604
697
  const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id);
605
698
  this.tasks = [...tasks];
606
699
  if (selectionCleared) this.selectedTaskId = void 0;
@@ -615,6 +708,13 @@ window.__ModuleLoader__.load({
615
708
  archiveTask(id) {
616
709
  const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now());
617
710
  if (!archived) return false;
711
+ if (this.deps.transport !== void 0) {
712
+ this.commitRemote({
713
+ kind: "archive",
714
+ taskId: id
715
+ }, id);
716
+ return true;
717
+ }
618
718
  this.tasks = [...tasks];
619
719
  this.persistAndNotify();
620
720
  return true;
@@ -623,7 +723,17 @@ window.__ModuleLoader__.load({
623
723
  restoreTask(id) {
624
724
  const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now());
625
725
  if (!archived) return false;
726
+ if (this.deps.transport !== void 0) {
727
+ this.commitRemote({
728
+ kind: "restore",
729
+ taskId: id
730
+ }, id).then((restored) => {
731
+ if (restored && this.selectedTaskId === id) this.closeTask();
732
+ });
733
+ return true;
734
+ }
626
735
  this.tasks = [...tasks];
736
+ if (this.selectedTaskId === id) this.selectedTaskId = void 0;
627
737
  this.persistAndNotify();
628
738
  return true;
629
739
  }
@@ -639,14 +749,21 @@ window.__ModuleLoader__.load({
639
749
  setSchedule(id, patch) {
640
750
  const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now());
641
751
  if (!applied) return false;
752
+ if (this.deps.transport !== void 0) {
753
+ this.commitRemote({
754
+ kind: "set-schedule",
755
+ taskId: id,
756
+ patch
757
+ }, id);
758
+ return true;
759
+ }
642
760
  this.tasks = [...tasks];
643
761
  this.persistAndNotify();
644
762
  return true;
645
763
  }
646
764
  /**
647
- * Roll a task's schedule forward (scheduler callback): persist the next due
648
- * instant and the trigger instant of this run. No-op when the task has no
649
- * schedule rule (it was deleted mid-tick, for example).
765
+ * Legacy pure-controller seam retained for migration-focused tests. The
766
+ * production browser never rolls schedules; the Host ledger owns them.
650
767
  */
651
768
  applyScheduleNextRun(id, nextRunAt, lastTriggeredAt) {
652
769
  const next = applyScheduleNextRun(this.tasks, id, nextRunAt, lastTriggeredAt, this.now());
@@ -654,12 +771,8 @@ window.__ModuleLoader__.load({
654
771
  this.persistAndNotify();
655
772
  }
656
773
  /**
657
- * Reload the ledger from the persisted store without notifying subscribers.
658
- * The scheduler calls this before every tick so a task deleted in another
659
- * tab (or a stale in-memory copy) can never be fired from this tab: the
660
- * fire decision and the subsequent roll-forward both run on the freshest
661
- * persisted truth. Deliberately silent — same-origin external changes still
662
- * reach subscribers through the storage-event subscription.
774
+ * Reload the legacy v1 store without notifying subscribers. Production v2
775
+ * reads Host snapshots instead; this remains only for isolated legacy tests.
663
776
  */
664
777
  reloadFromStore() {
665
778
  this.tasks = this.deps.store.load();
@@ -679,11 +792,16 @@ window.__ModuleLoader__.load({
679
792
  */
680
793
  async runTask(id) {
681
794
  const task = this.tasks.find((candidate) => candidate.id === id);
682
- if (task === void 0 || task.status === "running") return false;
795
+ if (task === void 0 || task.archivedAt !== void 0 || task.status === "running") return false;
796
+ if (this.deps.transport !== void 0) return await this.commitRemote({
797
+ kind: "run",
798
+ taskId: id
799
+ }, id);
683
800
  const { task: next, execution } = startExecution(task, this.now(), this.uuid());
684
801
  this.tasks = this.tasks.map((candidate) => candidate.id === id ? next : candidate);
685
802
  this.persistAndNotify();
686
803
  this.activeExecutionIds.add(execution.id);
804
+ if (this.deps.exec === void 0) throw new Error("legacy execution service is unavailable");
687
805
  await this.deps.exec.run(next, execution, (event) => {
688
806
  this.handleExecutionEvent(event);
689
807
  });
@@ -692,7 +810,14 @@ window.__ModuleLoader__.load({
692
810
  /** Re-run a settled task: move it back to 'todo' first, then execute. */
693
811
  async rerunTask(id) {
694
812
  const task = this.tasks.find((candidate) => candidate.id === id);
695
- if (task === void 0) return;
813
+ if (task === void 0 || task.archivedAt !== void 0) return;
814
+ if (this.deps.transport !== void 0) {
815
+ await this.commitRemote({
816
+ kind: "rerun",
817
+ taskId: id
818
+ }, id);
819
+ return;
820
+ }
696
821
  if (task.status !== "running") {
697
822
  this.tasks = this.tasks.map((candidate) => candidate.id === id ? withStatus(candidate, "todo", this.now()) : candidate);
698
823
  this.persistAndNotify();
@@ -711,7 +836,7 @@ window.__ModuleLoader__.load({
711
836
  }
712
837
  /** Reconcile running tasks and close the board when the user navigates. */
713
838
  onSessionsChanged() {
714
- this.scheduleReconcile();
839
+ if (this.deps.transport === void 0) this.scheduleReconcile();
715
840
  if (!this.boardOpen) return;
716
841
  const current = currentOf(this.deps.sessions);
717
842
  if (current !== this.lastCurrent) this.closeBoard();
@@ -739,6 +864,7 @@ window.__ModuleLoader__.load({
739
864
  }
740
865
  /** Settle tasks left 'running' whose sessions already finished. */
741
866
  async reconcileRunningTasks() {
867
+ if (this.deps.exec === void 0) return;
742
868
  if (this.reconcileInFlight) return;
743
869
  this.reconcileInFlight = true;
744
870
  try {
@@ -769,442 +895,120 @@ window.__ModuleLoader__.load({
769
895
  }
770
896
  }
771
897
  persistAndNotify() {
772
- this.deps.store.save(this.tasks);
898
+ if (this.deps.transport === void 0) this.deps.store.save(this.tasks);
773
899
  this.notify();
774
900
  }
775
- notify() {
776
- for (const fn of [...this.listeners]) fn();
777
- }
778
- };
779
- /** Record which session ran an execution (once the execution service reports it). */
780
- function attachSessionId(task, executionId, sessionId, now) {
781
- return {
782
- ...task,
783
- updatedAt: now,
784
- executions: task.executions.map((execution) => execution.id === executionId ? {
785
- ...execution,
786
- sessionId
787
- } : execution)
788
- };
789
- }
790
- //#endregion
791
- //#region src/core/execution.ts
792
- /** Human copy for a run failure. */
793
- function messageOf(error) {
794
- if (error instanceof Error) return error.message;
795
- return String(error);
796
- }
797
- /**
798
- * Whether a rejected preset switch actually means "the session already runs
799
- * this preset" (the host's agent-preset-conflict with a matching
800
- * existingPreset). A blank-session reuse race can produce this even though
801
- * the requested composition is in place.
802
- */
803
- function presetAlreadyRuns(error, mode) {
804
- if (typeof error !== "object" || error === null) return false;
805
- const details = error.details;
806
- if (typeof details !== "object" || details === null) return false;
807
- return details.existingPreset === mode;
808
- }
809
- /** Whether a `turn/end` payload closed the turn with an error reason. */
810
- function isErrorTurnEnd(data) {
811
- if (typeof data !== "object" || data === null) return false;
812
- const reason = data.reason;
813
- return typeof reason === "object" && reason !== null && reason.kind === "error";
814
- }
815
- /**
816
- * Run one task to completion (or to a settled failure).
817
- *
818
- * @param task - the task being executed.
819
- * @param execution - the freshly opened execution record (id + start time).
820
- * @param onEvent - callback for started/settled events.
821
- * @returns resolves when the run settles (or fails to start); never rejects —
822
- * every failure path is reported as a settled event.
823
- */
824
- var ExecutionService = class {
825
- env;
826
- /**
827
- * In-flight preset switches keyed by `sessionId\u0000mode`. The session
828
- * list mirror can lag behind a just-applied switch, so concurrent runs
829
- * against one blank session would otherwise each issue the same select
830
- * RPC (an amplified storm); sharing one in-flight call keeps the wire to
831
- * a single request. The settled entry is forgotten so a later run can
832
- * switch again.
833
- */
834
- presetSwitches = /* @__PURE__ */ new Map();
835
- /** @param env - the runtime faces (real or fake). */
836
- constructor(env) {
837
- this.env = env;
838
- }
839
- async run(task, execution, onEvent) {
840
- const settleFailed = (error) => {
841
- onEvent({
842
- kind: "settled",
843
- taskId: task.id,
844
- executionId: execution.id,
845
- outcome: "failed",
846
- error
847
- });
848
- };
901
+ async commitRemote(action, taskId) {
902
+ if (this.deps.transport === void 0) return true;
903
+ if (taskId === void 0) return await this.performRemote(action);
904
+ const operation = (this.taskQueues.get(taskId) ?? Promise.resolve()).catch(() => {}).then(async () => await this.performRemote(action));
905
+ const tail = operation.then(() => {}, () => {});
906
+ this.taskQueues.set(taskId, tail);
907
+ this.pendingTaskIds.add(taskId);
908
+ this.notify();
849
909
  try {
850
- const sessionId = await this.connectSession(task.workspaceId);
851
- onEvent({
852
- kind: "started",
853
- taskId: task.id,
854
- executionId: execution.id,
855
- sessionId
856
- });
857
- const driver = this.driverOf(sessionId);
858
- if (driver === void 0) {
859
- settleFailed("execution session is not ready");
860
- return;
861
- }
862
- if (!await this.applyMode(driver, task, sessionId, settleFailed)) return;
863
- if (!await this.applyPermission(driver, task, settleFailed)) return;
864
- await driver.rename(task.title).catch(() => {});
865
- const baseline = driver.getSnapshot().turnEnds.size;
866
- const accepted = await this.sendPrompt(driver, task);
867
- if (!accepted.ok) {
868
- settleFailed(messageOf(accepted.error));
869
- return;
910
+ return await operation;
911
+ } finally {
912
+ if (this.taskQueues.get(taskId) === tail) {
913
+ this.taskQueues.delete(taskId);
914
+ this.pendingTaskIds.delete(taskId);
915
+ this.notify();
870
916
  }
871
- this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline);
872
- } catch (error) {
873
- settleFailed(messageOf(error));
874
917
  }
875
918
  }
876
- /**
877
- * Recompose the execution session's agent from the task-pinned preset.
878
- * No-op when the task pins none or the session already runs it; fails the
879
- * run when the session is no longer blank, the preset face is missing, or
880
- * the wire refuses.
881
- */
882
- async applyMode(driver, task, sessionId, settleFailed) {
883
- const mode = task.mode;
884
- if (mode === void 0 || mode === "") return true;
885
- const summary = this.env.sessions.list.getSnapshot().byId[sessionId];
886
- if (summary?.blank === false) {
887
- settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`);
888
- return false;
889
- }
890
- if (summary?.agentPreset === mode) return true;
891
- const presets = this.env.presets;
892
- if (presets === void 0) {
893
- settleFailed(`this deployment does not support agent presets (task asks for ${mode})`);
894
- return false;
895
- }
919
+ async performRemote(action) {
920
+ const transport = this.deps.transport;
921
+ if (transport === void 0) return true;
922
+ this.transportError = void 0;
923
+ this.notify();
896
924
  try {
897
- const result = await this.switchPreset(presets, sessionId, mode);
898
- if (!result.ok) {
899
- if (presetAlreadyRuns(result.error, mode)) {
900
- this.env.sessions.noteAgentPreset?.(sessionId, mode);
901
- return true;
902
- }
903
- settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`);
904
- return false;
905
- }
925
+ return this.acceptRemote(await transport.action(action)) || await this.refreshRemote();
906
926
  } catch (error) {
907
- settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`);
927
+ await this.refreshRemote(messageOf(error));
908
928
  return false;
909
929
  }
910
- this.env.sessions.noteAgentPreset?.(sessionId, mode);
911
- return true;
912
- }
913
- /**
914
- * One in-flight `select` per (session, preset): concurrent runs against
915
- * the same blank session share the same wire call instead of each issuing
916
- * a duplicate RPC. The entry is removed once the call settles, so a later
917
- * run (after a shared failure, say) issues a fresh switch.
918
- */
919
- switchPreset(presets, sessionId, mode) {
920
- const key = `${sessionId}\u0000${mode}`;
921
- const inflight = this.presetSwitches.get(key);
922
- if (inflight !== void 0) return inflight;
923
- const attempt = presets.select(sessionId, mode).finally(() => {
924
- if (this.presetSwitches.get(key) === attempt) this.presetSwitches.delete(key);
925
- });
926
- this.presetSwitches.set(key, attempt);
927
- return attempt;
928
930
  }
929
- /**
930
- * Apply the task-pinned permission preset through the `/permission <id>`
931
- * slash command. No-op when the task pins none; fails the run when the
932
- * admission is rejected or no command claimed the line.
933
- */
934
- async applyPermission(driver, task, settleFailed) {
935
- const permission = task.permission;
936
- if (permission === void 0) return true;
937
- const line = `/permission ${permission}`;
931
+ async initializeRemote() {
932
+ if (this.remoteInitialization !== void 0) return await this.remoteInitialization;
933
+ const initialization = this.doInitializeRemote();
934
+ this.remoteInitialization = initialization;
938
935
  try {
939
- const result = await driver.command(line);
940
- if (!result.ok) {
941
- settleFailed(`permission command rejected: ${messageOf(result.error)}`);
942
- return false;
943
- }
944
- if (!result.matched) {
945
- settleFailed(`permission command not recognized: ${line}`);
946
- return false;
947
- }
948
- } catch (error) {
949
- settleFailed(`permission command failed: ${messageOf(error)}`);
950
- return false;
951
- }
952
- return true;
953
- }
954
- /**
955
- * Inspect a reloaded/background task that was left 'running' and emit a
956
- * settled event when its session already finished.
957
- *
958
- * A session that was never opened keeps a cold conversation snapshot (the
959
- * runtime only maintains the window for the staged/current session), so the
960
- * settled outcome is decided by the strongest available signal, in order:
961
- * 1. the list summary — missing session → cancelled; still running → pending;
962
- * 2. a warm conversation snapshot → `lastAgentError` decides failed/succeeded;
963
- * 3. the raw history tail (when a history face is wired) — a `turn/end`
964
- * error reason proves failure;
965
- * 4. otherwise a finished session counts as succeeded.
966
- *
967
- * @param task - a task whose latest execution has no endedAt.
968
- * @returns a settled event when the session state proves completion, else undefined.
969
- */
970
- async reconcile(task) {
971
- const execution = task.executions[task.executions.length - 1];
972
- if (execution === void 0 || execution.sessionId === void 0 || execution.endedAt !== void 0) return void 0;
973
- const list = this.env.sessions.list.getSnapshot();
974
- if (list.phase !== "ready") return void 0;
975
- const summary = list.byId[execution.sessionId];
976
- if (summary === void 0) return {
977
- kind: "settled",
978
- taskId: task.id,
979
- executionId: execution.id,
980
- outcome: "cancelled",
981
- error: "execution session no longer exists"
982
- };
983
- if (summary.running) return void 0;
984
- const driver = this.driverOf(execution.sessionId);
985
- if (driver !== void 0) {
986
- const snapshot = driver.getSnapshot();
987
- if (snapshot.turnEnds.size > 0) {
988
- const outcome = snapshot.lastAgentError !== null ? "failed" : "succeeded";
989
- return {
990
- kind: "settled",
991
- taskId: task.id,
992
- executionId: execution.id,
993
- outcome,
994
- error: snapshot.lastAgentError ?? void 0
995
- };
996
- }
936
+ return await initialization;
937
+ } finally {
938
+ if (this.remoteInitialization === initialization) this.remoteInitialization = void 0;
997
939
  }
998
- if (await this.historyShowsFailure(execution.sessionId)) return {
999
- kind: "settled",
1000
- taskId: task.id,
1001
- executionId: execution.id,
1002
- outcome: "failed",
1003
- error: "agent turn failed"
1004
- };
1005
- return {
1006
- kind: "settled",
1007
- taskId: task.id,
1008
- executionId: execution.id,
1009
- outcome: "succeeded"
1010
- };
1011
940
  }
1012
- /** Best-effort failure probe over the raw history tail (false when unavailable). */
1013
- async historyShowsFailure(sessionId) {
1014
- const history = this.env.history;
1015
- if (history === void 0) return false;
941
+ async doInitializeRemote() {
942
+ const transport = this.deps.transport;
943
+ if (transport === void 0) return true;
1016
944
  try {
1017
- const tail = await history.loadTail(sessionId);
1018
- if (tail === void 0) return false;
1019
- return tail.events.some((event) => event.type === "turn/end" && isErrorTurnEnd(event.data));
945
+ this.acceptRemote(await transport.bootstrap(this.tasks));
946
+ if (!this.remoteSubscribed) {
947
+ this.remoteSubscribed = true;
948
+ this.disposers.push(transport.subscribe(() => {
949
+ this.refreshRemote();
950
+ }));
951
+ }
952
+ return true;
1020
953
  } catch (error) {
1021
- console.error("[dsh-task-board] history failure probe failed", error);
954
+ this.transportError = messageOf(error);
955
+ this.notify();
1022
956
  return false;
1023
957
  }
1024
958
  }
1025
- async connectSession(taskWorkspaceId) {
1026
- const workspace = this.env.workspaces.list.getSnapshot();
1027
- if (taskWorkspaceId !== void 0 && taskWorkspaceId !== "") {
1028
- if (!workspace.items.some((item) => item.workspaceId === taskWorkspaceId)) throw new Error(`task workspace is not available: ${taskWorkspaceId}`);
1029
- return this.env.workspaces.connectWorkspace(taskWorkspaceId);
1030
- }
1031
- const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId;
1032
- if (workspaceId === void 0) throw new Error("no workspace available to run the task in");
1033
- return this.env.workspaces.connectWorkspace(workspaceId);
1034
- }
1035
- driverOf(sessionId) {
1036
- return this.env.sessions.binding(sessionId)?.session;
1037
- }
1038
- async sendPrompt(driver, task) {
1039
- const text = task.prompt.trim() !== "" ? task.prompt : task.title;
959
+ async refreshRemote(preserveError) {
960
+ const transport = this.deps.transport;
961
+ if (transport === void 0) return true;
1040
962
  try {
1041
- return await driver.prompt([{
1042
- type: "text",
1043
- text
1044
- }], "queue");
963
+ this.acceptRemote(await transport.state());
964
+ if (preserveError !== void 0) {
965
+ this.transportError = preserveError;
966
+ this.notify();
967
+ }
968
+ return true;
1045
969
  } catch (error) {
1046
- return {
1047
- ok: false,
1048
- error
1049
- };
970
+ this.transportError = preserveError ?? messageOf(error);
971
+ this.notify();
972
+ return false;
1050
973
  }
1051
974
  }
1052
- /**
1053
- * Subscribe to the execution session and settle the run once the accepted
1054
- * turn completes (turn counter advanced past the acceptance baseline and
1055
- * the session is no longer running). Never settles while the session is
1056
- * still running; unsubscribes on settle.
1057
- */
1058
- watchForSettlement(driver, taskId, executionId, onEvent, baseline) {
1059
- let settled = false;
1060
- let unsubscribe = () => {};
1061
- const check = () => {
1062
- if (settled) return;
1063
- const snapshot = driver.getSnapshot();
1064
- if (snapshot.running || snapshot.turnEnds.size <= baseline) return;
1065
- settled = true;
1066
- unsubscribe();
1067
- onEvent({
1068
- kind: "settled",
1069
- taskId,
1070
- executionId,
1071
- outcome: snapshot.lastAgentError !== null ? "failed" : "succeeded",
1072
- error: snapshot.lastAgentError ?? void 0
1073
- });
975
+ acceptRemote(snapshot) {
976
+ if (this.hostState?.scheduler.ledgerId === snapshot.scheduler.ledgerId && this.hostState !== void 0 && snapshot.revision < this.hostState.revision) return false;
977
+ this.tasks = [...snapshot.tasks];
978
+ this.hostState = {
979
+ revision: snapshot.revision,
980
+ scheduler: snapshot.scheduler,
981
+ power: snapshot.power
1074
982
  };
1075
- unsubscribe = driver.subscribe(check);
1076
- check();
1077
- }
1078
- };
1079
- //#endregion
1080
- //#region src/core/scheduler.ts
1081
- /**
1082
- * Browser-side scheduler: the heartbeat behind scheduled task runs.
1083
- *
1084
- * The board is a pure client plugin with no server channel, so "定时任务"
1085
- * lives in the tab: a timer ticks every minute (plus immediately on tab
1086
- * visibility recovery) and triggers any task whose `schedule.nextRunAt` is
1087
- * due, rolling the schedule forward to the next cron match before triggering
1088
- * so the same tick never double-fires. Missed runs are skipped, never queued:
1089
- * a task still running at its due instant is skipped by the controller's
1090
- * runTask guard and simply waits for the next cron match.
1091
- *
1092
- * A deleted task can never fire again from a stale in-memory copy: every
1093
- * tick first re-reads the persisted ledger through the optional `refresh`
1094
- * hook (the controller reloads its store), so the fire decision and the
1095
- * subsequent roll-forward both run on the freshest persisted truth.
1096
- *
1097
- * The ticker is controlled: `start` arms a single interval guarded by an
1098
- * idempotence check (a second start while running is a no-op, so wiring can
1099
- * never leak a duplicate timer), and `stop`/`dispose` clear the timer and
1100
- * drop the environment listeners. The board unmount path calls stop/dispose,
1101
- * so no interval or listener outlives the board.
1102
- *
1103
- * Framework-free: all runtime access flows through the injected deps
1104
- * (structural faces), so tests drive ticks directly without timers.
1105
- */
1106
- /**
1107
- * The schedule heartbeat (see module doc). `tick` is public so tests and
1108
- * callers can drive a check without waiting for the interval.
1109
- */
1110
- var SchedulerService = class {
1111
- deps;
1112
- timer;
1113
- environmentListener;
1114
- disposed = false;
1115
- started = false;
1116
- /** Cron expressions that matched no instant in the scan window, per task —
1117
- * remembered so a never-matching rule is not re-scanned on every tick. */
1118
- unmatchableRepair = /* @__PURE__ */ new Map();
1119
- /** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
1120
- constructor(deps) {
1121
- this.deps = deps;
1122
- }
1123
- /**
1124
- * Start ticking: one immediate check (catch-up after reload) + the interval.
1125
- * Single-instance: a second start while already armed is a no-op, so a
1126
- * re-entrant mount can never stack a duplicate timer.
1127
- */
1128
- start() {
1129
- if (this.disposed) return;
1130
- if (this.started) return;
1131
- this.started = true;
1132
- this.tick();
1133
- this.timer = setInterval(() => {
1134
- this.tick();
1135
- }, this.deps.tickMs ?? 6e4);
1136
- if (this.deps.environment !== void 0) {
1137
- this.environmentListener = () => {
1138
- this.tick();
1139
- };
1140
- this.deps.environment.addEventListener("visibilitychange", this.environmentListener);
1141
- }
1142
- }
1143
- /**
1144
- * Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
1145
- * callers that treat the scheduler as a controlled ticker; `dispose` is an
1146
- * alias.
1147
- */
1148
- stop() {
1149
- this.dispose();
1150
- }
1151
- /** Stop ticking and drop listeners (idempotent). */
1152
- dispose() {
1153
- if (this.disposed && this.timer === void 0 && this.environmentListener === void 0) return;
1154
- this.disposed = true;
1155
- this.started = false;
1156
- if (this.timer !== void 0) {
1157
- clearInterval(this.timer);
1158
- this.timer = void 0;
1159
- }
1160
- if (this.environmentListener !== void 0 && this.deps.environment !== void 0) {
1161
- this.deps.environment.removeEventListener("visibilitychange", this.environmentListener);
1162
- this.environmentListener = void 0;
1163
- }
983
+ this.transportError = void 0;
984
+ if (this.selectedTaskId !== void 0 && !this.tasks.some((task) => task.id === this.selectedTaskId)) this.selectedTaskId = void 0;
985
+ if (!this.archiveView && this.selectedTaskId !== void 0 && this.tasks.find((task) => task.id === this.selectedTaskId)?.archivedAt !== void 0) this.selectedTaskId = void 0;
986
+ this.notify();
987
+ return true;
1164
988
  }
1165
- /**
1166
- * Check every enabled schedule and trigger the due ones. Idempotent per
1167
- * task per tick: the schedule is rolled forward only after runTask accepts
1168
- * the run, so a rejected run keeps its due slot and is retried on the next
1169
- * tick instead of being silently dropped.
1170
- */
1171
- async tick() {
1172
- if (this.disposed) return;
1173
- if (this.deps.ready !== void 0 && !this.deps.ready()) return;
1174
- this.deps.refresh?.();
1175
- const now = this.deps.now();
1176
- for (const task of this.deps.tasks()) {
1177
- const schedule = task.schedule;
1178
- if (schedule === void 0 || !schedule.enabled) continue;
1179
- if (schedule.nextRunAt === void 0) {
1180
- if (this.unmatchableRepair.get(task.id) === schedule.cron) continue;
1181
- const repaired = nextRunAtMs(schedule.cron, now);
1182
- if (repaired === void 0) {
1183
- this.unmatchableRepair.set(task.id, schedule.cron);
1184
- continue;
1185
- }
1186
- this.unmatchableRepair.delete(task.id);
1187
- this.deps.applySchedule(task.id, repaired, void 0);
1188
- continue;
1189
- }
1190
- if (schedule.nextRunAt > now) continue;
1191
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
1192
- if (await this.deps.runTask(task.id)) this.deps.applySchedule(task.id, next, now);
1193
- }
989
+ notify() {
990
+ for (const fn of [...this.listeners]) fn();
1194
991
  }
1195
992
  };
993
+ /** Record which session ran an execution (once the execution service reports it). */
994
+ function attachSessionId(task, executionId, sessionId, now) {
995
+ return {
996
+ ...task,
997
+ updatedAt: now,
998
+ executions: task.executions.map((execution) => execution.id === executionId ? {
999
+ ...execution,
1000
+ sessionId
1001
+ } : execution)
1002
+ };
1003
+ }
1196
1004
  //#endregion
1197
1005
  //#region src/core/store.ts
1198
1006
  /**
1199
- * Task persistence: a small storage seam with a localStorage backend.
1007
+ * Legacy v1 browser persistence and the store seam used by pure client tests.
1200
1008
  *
1201
- * The task-board client plugin runs in the browser, and dsh exposes no
1202
- * browser-writable file channel (same conclusion the skin-center research
1203
- * reached for cordis.patch.yml), so tasks persist in the browser's
1204
- * localStorage under a versioned key — the same persistence mechanism dsh's
1205
- * own client snapshot stores use (`createSnapshotStore` persist). Data
1206
- * survives page refreshes and dsh restarts (same origin), and survives
1207
- * plugin uninstall (the key is simply left in place).
1009
+ * Production v2 state is Host-authoritative. This backend is retained only to
1010
+ * read `dsh.taskBoard.v1` for one-time import; the old value is never removed,
1011
+ * so it remains a read-only rollback copy after migration.
1208
1012
  *
1209
1013
  * The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
1210
1014
  * channel later); tests run against the in-memory backend and a jsdom
@@ -1402,8 +1206,12 @@ window.__ModuleLoader__.load({
1402
1206
  "board.status.done": "已完成",
1403
1207
  "board.status.failed": "已失败",
1404
1208
  "board.runs": "次执行",
1209
+ "board.pending": "正在提交",
1405
1210
  "board.updated": "更新于",
1406
1211
  "board.created": "创建于",
1212
+ "board.hostError": "Host 操作失败:{error}",
1213
+ "board.retryHost": "重试连接 Host",
1214
+ "board.hostMeta": "Host 时区 {timeZone} · revision {revision}",
1407
1215
  "new.title": "标题",
1408
1216
  "new.titlePlaceholder": "一句话描述要做什么",
1409
1217
  "new.description": "描述",
@@ -1472,11 +1280,17 @@ window.__ModuleLoader__.load({
1472
1280
  "detail.executionSettings": "执行设置",
1473
1281
  "exec.hint": "执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。",
1474
1282
  "settings.title": "任务看板",
1475
- "settings.description": "控制看板在 agent 系统提示中的播报行为。",
1283
+ "settings.description": "控制 Host 任务看板、agent 播报与运行期间的系统空闲睡眠保护。",
1476
1284
  "settings.enabled": "启用任务看板",
1477
1285
  "settings.enabledHint": "关闭后隐藏侧边栏入口与看板视图。",
1478
1286
  "settings.announceToAgent": "向 agent 播报任务看板",
1479
1287
  "settings.announceToAgentHint": "开启:每条 agent 系统提示都会包含本看板的说明;关闭:不播报,agent 仅在用户主动提及时了解看板。",
1288
+ "settings.preventIdleSleep": "阻止系统空闲睡眠",
1289
+ "settings.preventIdleSleepHint": "默认关闭。开启后,只要存在运行中的 DSH 会话、已启用的定时任务或会话状态尚未确认,Host 就阻止整机因空闲睡眠;屏幕仍可自动关闭。",
1290
+ "settings.powerStatus": "平台:{platform};保护状态:{phase};运行会话:{running};已启用计划:{schedules}",
1291
+ "settings.powerBoundary": "这可能增加电池消耗。合盖、手动睡眠、休眠、关机、低电量或企业策略不在保证范围内,也不会唤醒已经睡眠的机器。",
1292
+ "settings.powerUnknown": "未知",
1293
+ "settings.powerError": "最近一次电源保护错误:{error}",
1480
1294
  "settings.inherit": "继承",
1481
1295
  "settings.on": "开",
1482
1296
  "settings.off": "关",
@@ -1513,8 +1327,12 @@ window.__ModuleLoader__.load({
1513
1327
  "board.status.done": "Done",
1514
1328
  "board.status.failed": "Failed",
1515
1329
  "board.runs": "runs",
1330
+ "board.pending": "Submitting",
1516
1331
  "board.updated": "Updated",
1517
1332
  "board.created": "Created",
1333
+ "board.hostError": "Host action failed: {error}",
1334
+ "board.retryHost": "Retry Host connection",
1335
+ "board.hostMeta": "Host time zone {timeZone} · revision {revision}",
1518
1336
  "new.title": "Title",
1519
1337
  "new.titlePlaceholder": "What should be done, in one line",
1520
1338
  "new.description": "Description",
@@ -1583,11 +1401,17 @@ window.__ModuleLoader__.load({
1583
1401
  "detail.executionSettings": "Execution Settings",
1584
1402
  "exec.hint": "Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session's agent preset; the permission is applied through the /permission command. Blank = runtime default.",
1585
1403
  "settings.title": "Task Board",
1586
- "settings.description": "How the board announces itself in each agent system prompt.",
1404
+ "settings.description": "Configure the Host task board, agent announcement, and idle-system-sleep protection while work is pending.",
1587
1405
  "settings.enabled": "Enable the task board",
1588
1406
  "settings.enabledHint": "When off, the sidebar entry and board view are hidden.",
1589
1407
  "settings.announceToAgent": "Announce the task board to agents",
1590
1408
  "settings.announceToAgentHint": "On: every agent system prompt includes a note about this board. Off: no announcement; agents learn about the board only when you mention it.",
1409
+ "settings.preventIdleSleep": "Prevent idle system sleep",
1410
+ "settings.preventIdleSleepHint": "Off by default. When enabled, the Host prevents idle system sleep while any DSH session runs, any schedule is enabled, or session state is not yet known. The display may still turn off.",
1411
+ "settings.powerStatus": "Platform: {platform}; protection: {phase}; running sessions: {running}; enabled schedules: {schedules}",
1412
+ "settings.powerBoundary": "This may use more battery. Lid close, manual sleep, hibernation, shutdown, low-battery actions, and enterprise policy are outside the guarantee; an already sleeping computer is not woken.",
1413
+ "settings.powerUnknown": "unknown",
1414
+ "settings.powerError": "Latest power-protection error: {error}",
1591
1415
  "settings.inherit": "Inherit",
1592
1416
  "settings.on": "On",
1593
1417
  "settings.off": "Off",
@@ -1717,7 +1541,7 @@ window.__ModuleLoader__.load({
1717
1541
  //#region src/client/board/NewTaskModal.tsx
1718
1542
  /**
1719
1543
  * New-task modal: title + description + the prompt that execution will send.
1720
- * Creates through the controller (which persists immediately).
1544
+ * Creates through the Host and closes only after the Host confirms it.
1721
1545
  */
1722
1546
  /** New-task form overlay. */
1723
1547
  function NewTaskModal({ controller, onClose }) {
@@ -1731,9 +1555,10 @@ window.__ModuleLoader__.load({
1731
1555
  const [scheduleCron, setScheduleCron] = (0, react.useState)("");
1732
1556
  const [scheduleError, setScheduleError] = (0, react.useState)(void 0);
1733
1557
  const [error, setError] = (0, react.useState)(void 0);
1558
+ const [pending, setPending] = (0, react.useState)(false);
1734
1559
  const [options, setOptions] = (0, react.useState)(controller.getSnapshot().executionOptions);
1735
1560
  (0, react.useEffect)(() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)), [controller]);
1736
- const submit = () => {
1561
+ const submit = async () => {
1737
1562
  if (scheduleEnabled) {
1738
1563
  const cron = scheduleCron.trim();
1739
1564
  if (cron === "" || !isValidCron(cron)) {
@@ -1741,7 +1566,8 @@ window.__ModuleLoader__.load({
1741
1566
  return;
1742
1567
  }
1743
1568
  }
1744
- if (controller.createTask({
1569
+ setPending(true);
1570
+ if (await controller.createTaskConfirmed({
1745
1571
  title,
1746
1572
  description,
1747
1573
  prompt,
@@ -1753,7 +1579,8 @@ window.__ModuleLoader__.load({
1753
1579
  cron: scheduleCron.trim()
1754
1580
  } : void 0
1755
1581
  }) === void 0) {
1756
- setError(t("new.required"));
1582
+ setPending(false);
1583
+ setError(controller.getSnapshot().transportError ?? t("new.required"));
1757
1584
  return;
1758
1585
  }
1759
1586
  onClose();
@@ -1964,6 +1791,7 @@ window.__ModuleLoader__.load({
1964
1791
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1965
1792
  type: "submit",
1966
1793
  className: board_module_css_default.primaryButton,
1794
+ disabled: pending,
1967
1795
  children: t("new.submit")
1968
1796
  })]
1969
1797
  })
@@ -1993,22 +1821,36 @@ window.__ModuleLoader__.load({
1993
1821
  * by the board, so the memo boundary is effective.
1994
1822
  */
1995
1823
  /** Compact relative/absolute time label. */
1996
- function formatTime(ms) {
1824
+ function formatHostTimestamp(ms, timeZone) {
1825
+ try {
1826
+ return new Intl.DateTimeFormat(void 0, {
1827
+ dateStyle: "medium",
1828
+ timeStyle: "medium",
1829
+ ...timeZone === void 0 ? {} : { timeZone }
1830
+ }).format(new Date(ms));
1831
+ } catch {
1832
+ return new Date(ms).toISOString();
1833
+ }
1834
+ }
1835
+ function formatTime(ms, timeZone) {
1997
1836
  const date = new Date(ms);
1998
1837
  const minutes = Math.floor((Date.now() - ms) / 6e4);
1999
1838
  if (minutes < 1) return t("time.justNow");
2000
1839
  if (minutes < 60) return `${minutes}m`;
2001
1840
  if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
1841
+ if (timeZone !== void 0) return formatHostTimestamp(ms, timeZone);
2002
1842
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
2003
1843
  }
2004
1844
  /** One card in a column. */
2005
- function TaskCardInner({ task, onClick }) {
1845
+ function TaskCardInner({ task, pending, timeZone, onClick }) {
2006
1846
  const latest = task.executions[task.executions.length - 1];
2007
1847
  const runs = task.executions.length;
1848
+ const archived = task.archivedAt !== void 0;
2008
1849
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2009
1850
  type: "button",
2010
1851
  className: board_module_css_default.card,
2011
- "data-status": task.status,
1852
+ "data-status": archived ? "archived" : task.status,
1853
+ "data-pending": pending || void 0,
2012
1854
  onClick,
2013
1855
  title: task.description !== "" ? task.description : task.title,
2014
1856
  children: [
@@ -2031,14 +1873,14 @@ window.__ModuleLoader__.load({
2031
1873
  formatTime(task.updatedAt)
2032
1874
  ]
2033
1875
  }),
2034
- task.schedule?.enabled === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1876
+ !archived && task.schedule?.enabled === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2035
1877
  className: board_module_css_default.cardSchedule,
2036
- title: task.schedule.nextRunAt !== void 0 ? `${t("card.scheduled")} · ${new Date(task.schedule.nextRunAt).toLocaleString()}` : t("card.scheduled"),
1878
+ title: task.schedule.nextRunAt !== void 0 ? `${t("card.scheduled")} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}` : t("card.scheduled"),
2037
1879
  children: t("card.scheduled")
2038
1880
  }),
2039
1881
  latest !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2040
1882
  className: board_module_css_default.cardRun,
2041
- "data-result": latest.result,
1883
+ "data-result": archived ? void 0 : latest.result,
2042
1884
  children: [
2043
1885
  runs,
2044
1886
  " ",
@@ -2050,13 +1892,17 @@ window.__ModuleLoader__.load({
2050
1892
  title: latest.sessionId,
2051
1893
  children: "⌁"
2052
1894
  }),
2053
- task.status === "running" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1895
+ !archived && (task.status === "running" || pending) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2054
1896
  className: board_module_css_default.cardSpinner,
2055
1897
  "aria-hidden": "true"
2056
1898
  })
2057
1899
  ]
2058
1900
  }),
2059
- latest !== void 0 && executionLabel(latest) === "running" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1901
+ !archived && pending && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1902
+ className: board_module_css_default.cardRunningLabel,
1903
+ children: [t("board.pending"), "…"]
1904
+ }),
1905
+ !archived && latest !== void 0 && executionLabel(latest) === "running" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2060
1906
  className: board_module_css_default.cardRunningLabel,
2061
1907
  children: [t("detail.result.running"), "…"]
2062
1908
  })
@@ -2123,7 +1969,7 @@ window.__ModuleLoader__.load({
2123
1969
  cancelled: "detail.result.cancelled"
2124
1970
  };
2125
1971
  /** One execution-history row. */
2126
- function ExecutionRow({ execution, onOpen }) {
1972
+ function ExecutionRow({ execution, timeZone, onOpen }) {
2127
1973
  const result = execution.result;
2128
1974
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
2129
1975
  className: board_module_css_default.executionRow,
@@ -2139,8 +1985,8 @@ window.__ModuleLoader__.load({
2139
1985
  children: [
2140
1986
  t("detail.executionStarted"),
2141
1987
  " ",
2142
- formatTime(execution.startedAt),
2143
- execution.endedAt !== void 0 && ` · ${t("detail.executionEnded")} ${formatTime(execution.endedAt)}`
1988
+ formatTime(execution.startedAt, timeZone),
1989
+ execution.endedAt !== void 0 && ` · ${t("detail.executionEnded")} ${formatTime(execution.endedAt, timeZone)}`
2144
1990
  ]
2145
1991
  }),
2146
1992
  execution.sessionId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -2160,7 +2006,7 @@ window.__ModuleLoader__.load({
2160
2006
  });
2161
2007
  }
2162
2008
  /** The execution-target editor: workspace / mode / permission pickers. */
2163
- function ExecutionSettingsSection({ controller, task }) {
2009
+ function ExecutionSettingsSection({ controller, task, pending }) {
2164
2010
  const [options, setOptions] = (0, react.useState)(controller.getSnapshot().executionOptions);
2165
2011
  (0, react.useEffect)(() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)), [controller]);
2166
2012
  const workspaceId = task.workspaceId ?? "";
@@ -2184,6 +2030,7 @@ window.__ModuleLoader__.load({
2184
2030
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2185
2031
  className: board_module_css_default.select,
2186
2032
  value: workspaceId,
2033
+ disabled: pending,
2187
2034
  onChange: (event) => {
2188
2035
  controller.updateTask(task.id, { workspaceId: event.target.value });
2189
2036
  },
@@ -2211,6 +2058,7 @@ window.__ModuleLoader__.load({
2211
2058
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2212
2059
  className: board_module_css_default.select,
2213
2060
  value: mode,
2061
+ disabled: pending,
2214
2062
  onChange: (event) => {
2215
2063
  controller.updateTask(task.id, { mode: event.target.value });
2216
2064
  },
@@ -2243,6 +2091,7 @@ window.__ModuleLoader__.load({
2243
2091
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2244
2092
  className: board_module_css_default.select,
2245
2093
  value: permission,
2094
+ disabled: pending,
2246
2095
  onChange: (event) => {
2247
2096
  controller.updateTask(task.id, { permission: event.target.value === "" ? void 0 : event.target.value });
2248
2097
  },
@@ -2259,13 +2108,14 @@ window.__ModuleLoader__.load({
2259
2108
  });
2260
2109
  }
2261
2110
  /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
2262
- function ScheduleSection({ controller, task }) {
2111
+ function ScheduleSection({ controller, task, pending }) {
2263
2112
  const schedule = task.schedule;
2264
2113
  const [cron, setCron] = (0, react.useState)(schedule?.cron ?? "0 9 * * *");
2265
2114
  const [enabled, setEnabled] = (0, react.useState)(schedule?.enabled ?? false);
2266
2115
  const [nextRunAt, setNextRunAt] = (0, react.useState)(schedule?.nextRunAt);
2267
2116
  const [lastTriggeredAt, setLastTriggeredAt] = (0, react.useState)(schedule?.lastTriggeredAt);
2268
2117
  const [error, setError] = (0, react.useState)(void 0);
2118
+ const timeZone = controller.getSnapshot().host?.scheduler.timeZone;
2269
2119
  (0, react.useEffect)(() => {
2270
2120
  setCron(schedule?.cron ?? "0 9 * * *");
2271
2121
  setEnabled(schedule?.enabled ?? false);
@@ -2298,8 +2148,10 @@ window.__ModuleLoader__.load({
2298
2148
  return;
2299
2149
  }
2300
2150
  setError(void 0);
2301
- if (next && trimmed !== schedule?.cron) controller.setSchedule(task.id, { cron: trimmed });
2302
- if (controller.setSchedule(task.id, { enabled: next })) setEnabled(next);
2151
+ if (controller.setSchedule(task.id, {
2152
+ enabled: next,
2153
+ ...next && trimmed !== schedule?.cron ? { cron: trimmed } : {}
2154
+ }) && !controller.isHostBacked()) setEnabled(next);
2303
2155
  };
2304
2156
  const applyPreset = (preset) => {
2305
2157
  if (preset === "") return;
@@ -2307,8 +2159,8 @@ window.__ModuleLoader__.load({
2307
2159
  setError(void 0);
2308
2160
  controller.setSchedule(task.id, { cron: preset });
2309
2161
  };
2310
- const nextLabel = !enabled || nextRunAt === void 0 ? t("detail.schedule.notScheduled") : nextRunAt <= Date.now() ? t("detail.schedule.dueSoon") : new Date(nextRunAt).toLocaleString();
2311
- const lastLabel = lastTriggeredAt === void 0 ? "—" : new Date(lastTriggeredAt).toLocaleString();
2162
+ const nextLabel = !enabled || nextRunAt === void 0 ? t("detail.schedule.notScheduled") : nextRunAt <= Date.now() ? t("detail.schedule.dueSoon") : formatHostTimestamp(nextRunAt, timeZone);
2163
+ const lastLabel = lastTriggeredAt === void 0 ? "—" : formatHostTimestamp(lastTriggeredAt, timeZone);
2312
2164
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2313
2165
  className: board_module_css_default.detailSection,
2314
2166
  children: [
@@ -2318,6 +2170,7 @@ window.__ModuleLoader__.load({
2318
2170
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2319
2171
  type: "checkbox",
2320
2172
  checked: enabled,
2173
+ disabled: pending,
2321
2174
  onChange: (event) => {
2322
2175
  toggleEnabled(event.target.checked);
2323
2176
  }
@@ -2328,6 +2181,7 @@ window.__ModuleLoader__.load({
2328
2181
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2329
2182
  className: `${board_module_css_default.input} ${board_module_css_default.scheduleInput}${error !== void 0 ? ` ${board_module_css_default.scheduleInputInvalid}` : ""}`,
2330
2183
  value: cron,
2184
+ disabled: pending,
2331
2185
  placeholder: "0 9 * * *",
2332
2186
  spellCheck: false,
2333
2187
  "aria-label": t("detail.schedule.cron"),
@@ -2344,6 +2198,7 @@ window.__ModuleLoader__.load({
2344
2198
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2345
2199
  className: board_module_css_default.schedulePreset,
2346
2200
  value: "",
2201
+ disabled: pending,
2347
2202
  "aria-label": t("detail.schedule.presets"),
2348
2203
  onChange: (event) => {
2349
2204
  applyPreset(event.target.value);
@@ -2379,12 +2234,17 @@ window.__ModuleLoader__.load({
2379
2234
  /** Task detail overlay. */
2380
2235
  function TaskDetail({ controller, task }) {
2381
2236
  const [confirmDelete, setConfirmDelete] = (0, react.useState)(false);
2382
- const running = task.status === "running";
2383
2237
  const [latest, setLatest] = (0, react.useState)(task);
2384
2238
  (0, react.useEffect)(() => {
2385
2239
  setLatest(task);
2386
2240
  }, [task]);
2387
2241
  const current = latest;
2242
+ const snapshot = controller.getSnapshot();
2243
+ const running = current.status === "running";
2244
+ const archived = current.archivedAt !== void 0;
2245
+ const pending = snapshot.pendingTaskIds.includes(current.id);
2246
+ const transportError = snapshot.transportError;
2247
+ const timeZone = snapshot.host?.scheduler.timeZone;
2388
2248
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2389
2249
  className: board_module_css_default.modalBackdrop,
2390
2250
  onMouseDown: (event) => {
@@ -2404,8 +2264,8 @@ window.__ModuleLoader__.load({
2404
2264
  }),
2405
2265
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2406
2266
  className: board_module_css_default.statusBadge,
2407
- "data-status": current.status,
2408
- children: t(STATUS_KEY[current.status])
2267
+ "data-status": archived ? "archived" : current.status,
2268
+ children: archived ? t("board.archive") : t(STATUS_KEY[current.status])
2409
2269
  }),
2410
2270
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2411
2271
  type: "button",
@@ -2421,6 +2281,21 @@ window.__ModuleLoader__.load({
2421
2281
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2422
2282
  className: board_module_css_default.detailBody,
2423
2283
  children: [
2284
+ transportError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2285
+ className: board_module_css_default.formError,
2286
+ children: [
2287
+ t("board.hostError", { error: transportError }),
2288
+ " ",
2289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2290
+ type: "button",
2291
+ className: board_module_css_default.linkButton,
2292
+ onClick: () => {
2293
+ controller.retryHostSync();
2294
+ },
2295
+ children: t("board.retryHost")
2296
+ })
2297
+ ]
2298
+ }),
2424
2299
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2425
2300
  className: board_module_css_default.detailSection,
2426
2301
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: t("detail.description") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -2435,14 +2310,15 @@ window.__ModuleLoader__.load({
2435
2310
  children: current.prompt !== "" ? current.prompt : current.title
2436
2311
  })]
2437
2312
  }),
2438
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExecutionSettingsSection, {
2313
+ !archived && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExecutionSettingsSection, {
2439
2314
  controller,
2440
- task: current
2441
- }),
2442
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScheduleSection, {
2315
+ task: current,
2316
+ pending
2317
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScheduleSection, {
2443
2318
  controller,
2444
- task: current
2445
- }),
2319
+ task: current,
2320
+ pending
2321
+ })] }),
2446
2322
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2447
2323
  className: board_module_css_default.detailSection,
2448
2324
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: t("detail.execution") }), current.executions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -2452,20 +2328,21 @@ window.__ModuleLoader__.load({
2452
2328
  className: board_module_css_default.executionList,
2453
2329
  children: [...current.executions].reverse().map((execution) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExecutionRow, {
2454
2330
  execution,
2331
+ timeZone,
2455
2332
  onOpen: (sessionId) => {
2456
2333
  controller.openSession(sessionId);
2457
2334
  }
2458
2335
  }, execution.id))
2459
2336
  })]
2460
2337
  }),
2461
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2338
+ !archived && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2462
2339
  className: board_module_css_default.detailSection,
2463
2340
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: t("board.status") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2464
2341
  className: board_module_css_default.moveRow,
2465
2342
  children: MANUAL_STATUSES.map((status) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2466
2343
  type: "button",
2467
2344
  className: board_module_css_default.ghostButton,
2468
- disabled: current.status === status || running,
2345
+ disabled: current.status === status || running || pending,
2469
2346
  onClick: () => {
2470
2347
  controller.moveTask(current.id, status);
2471
2348
  },
@@ -2478,36 +2355,42 @@ window.__ModuleLoader__.load({
2478
2355
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("footer", {
2479
2356
  className: board_module_css_default.detailFooter,
2480
2357
  children: [
2481
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2358
+ !archived && pending && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2359
+ className: board_module_css_default.detailMeta,
2360
+ children: [t("board.pending"), "…"]
2361
+ }),
2362
+ !archived && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2482
2363
  type: "button",
2483
2364
  className: board_module_css_default.primaryButton,
2484
- disabled: running,
2365
+ disabled: running || pending,
2485
2366
  onClick: () => {
2486
- controller.closeTask();
2487
- controller.rerunTask(current.id);
2367
+ controller.rerunTask(current.id).then(() => {
2368
+ if (controller.getSnapshot().transportError === void 0) controller.closeTask();
2369
+ });
2488
2370
  },
2489
2371
  children: current.executions.length === 0 ? t("detail.run") : t("detail.rerun")
2490
2372
  }),
2491
- current.archivedAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2373
+ archived ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2492
2374
  type: "button",
2493
2375
  className: board_module_css_default.primaryButton,
2376
+ disabled: pending,
2494
2377
  onClick: () => {
2495
2378
  controller.restoreTask(current.id);
2496
- controller.closeTask();
2497
2379
  },
2498
2380
  children: t("detail.restore")
2499
2381
  }) : (current.status === "done" || current.status === "failed") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2500
2382
  type: "button",
2501
2383
  className: board_module_css_default.ghostButton,
2384
+ disabled: pending,
2502
2385
  onClick: () => {
2503
2386
  controller.archiveTask(current.id);
2504
- controller.closeTask();
2505
2387
  },
2506
2388
  children: t("detail.archive")
2507
2389
  }),
2508
2390
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2509
2391
  type: "button",
2510
2392
  className: board_module_css_default.dangerButton,
2393
+ disabled: pending,
2511
2394
  onClick: () => {
2512
2395
  setConfirmDelete(true);
2513
2396
  },
@@ -2518,8 +2401,8 @@ window.__ModuleLoader__.load({
2518
2401
  children: [
2519
2402
  t("board.created"),
2520
2403
  " ",
2521
- formatTime(current.createdAt),
2522
- current.archivedAt !== void 0 && ` · ${t("detail.archivedAt", { time: formatTime(current.archivedAt) })}`
2404
+ formatTime(current.createdAt, timeZone),
2405
+ archived && ` · ${t("detail.archivedAt", { time: formatTime(current.archivedAt, timeZone) })}`
2523
2406
  ]
2524
2407
  })
2525
2408
  ]
@@ -2536,7 +2419,6 @@ window.__ModuleLoader__.load({
2536
2419
  onConfirm: () => {
2537
2420
  setConfirmDelete(false);
2538
2421
  controller.deleteTask(current.id);
2539
- controller.closeTask();
2540
2422
  }
2541
2423
  })]
2542
2424
  });
@@ -2560,9 +2442,11 @@ window.__ModuleLoader__.load({
2560
2442
  * re-renders only when its own task changes — not when a sibling card status,
2561
2443
  * the filter, or the selection moves.
2562
2444
  */
2563
- const MemoTaskCard = (0, react.memo)(function MemoTaskCard({ task, onOpen }) {
2445
+ const MemoTaskCard = (0, react.memo)(function MemoTaskCard({ task, pending, timeZone, onOpen }) {
2564
2446
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
2565
2447
  task,
2448
+ pending,
2449
+ timeZone,
2566
2450
  onClick: (0, react.useCallback)(() => {
2567
2451
  onOpen(task.id);
2568
2452
  }, [task.id, onOpen])
@@ -2603,6 +2487,13 @@ window.__ModuleLoader__.load({
2603
2487
  className: board_module_css_default.boardTitle,
2604
2488
  children: t("board.title")
2605
2489
  }),
2490
+ snapshot.host !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2491
+ className: board_module_css_default.detailMeta,
2492
+ children: t("board.hostMeta", {
2493
+ revision: String(snapshot.host.revision),
2494
+ timeZone: snapshot.host.scheduler.timeZone
2495
+ })
2496
+ }),
2606
2497
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2607
2498
  className: board_module_css_default.search,
2608
2499
  type: "search",
@@ -2631,6 +2522,21 @@ window.__ModuleLoader__.load({
2631
2522
  })
2632
2523
  ]
2633
2524
  }),
2525
+ snapshot.transportError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2526
+ className: board_module_css_default.formError,
2527
+ children: [
2528
+ t("board.hostError", { error: snapshot.transportError }),
2529
+ " ",
2530
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2531
+ type: "button",
2532
+ className: board_module_css_default.linkButton,
2533
+ onClick: () => {
2534
+ controller.retryHostSync();
2535
+ },
2536
+ children: t("board.retryHost")
2537
+ })
2538
+ ]
2539
+ }),
2634
2540
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2635
2541
  className: board_module_css_default.columns,
2636
2542
  children: archiveView ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
@@ -2649,6 +2555,8 @@ window.__ModuleLoader__.load({
2649
2555
  className: board_module_css_default.cards,
2650
2556
  children: [visible.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemoTaskCard, {
2651
2557
  task,
2558
+ pending: snapshot.pendingTaskIds.includes(task.id),
2559
+ timeZone: snapshot.host?.scheduler.timeZone,
2652
2560
  onOpen: openTask
2653
2561
  }, task.id)), visible.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2654
2562
  className: board_module_css_default.columnEmpty,
@@ -2681,6 +2589,8 @@ window.__ModuleLoader__.load({
2681
2589
  className: board_module_css_default.cards,
2682
2590
  children: [tasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemoTaskCard, {
2683
2591
  task,
2592
+ pending: snapshot.pendingTaskIds.includes(task.id),
2593
+ timeZone: snapshot.host?.scheduler.timeZone,
2684
2594
  onOpen: openTask
2685
2595
  }, task.id)), tasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2686
2596
  className: board_module_css_default.columnEmpty,
@@ -3038,7 +2948,7 @@ window.__ModuleLoader__.load({
3038
2948
  children: props.t("settings.readOnly")
3039
2949
  }) : null,
3040
2950
  props.children,
3041
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2951
+ props.hideFooter === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3042
2952
  className: settings_card_module_css_default.footer,
3043
2953
  children: [
3044
2954
  state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
@@ -3384,14 +3294,19 @@ window.__ModuleLoader__.load({
3384
3294
  store;
3385
3295
  /** @param scope - the bound settings scope for the `task-board` namespace. */
3386
3296
  constructor(scope) {
3387
- this.form = new CardForm(scope, [booleanField("enabled"), booleanField("announceToAgent")]);
3297
+ this.form = new CardForm(scope, [
3298
+ booleanField("enabled"),
3299
+ booleanField("announceToAgent"),
3300
+ booleanField("preventIdleSleep")
3301
+ ]);
3388
3302
  this.store = this.form.bind(() => this.projection());
3389
3303
  }
3390
3304
  projection() {
3391
3305
  return {
3392
3306
  ...this.form.shell(),
3393
3307
  enabled: this.form.field("enabled"),
3394
- announceToAgent: this.form.field("announceToAgent")
3308
+ announceToAgent: this.form.field("announceToAgent"),
3309
+ preventIdleSleep: this.form.field("preventIdleSleep")
3395
3310
  };
3396
3311
  }
3397
3312
  /**
@@ -3421,6 +3336,29 @@ window.__ModuleLoader__.load({
3421
3336
  const { t } = props;
3422
3337
  const state = props.useTaskBoardSettingsCard((snapshot) => snapshot);
3423
3338
  const disabled = !state.writable;
3339
+ const [power, setPower] = (0, react.useState)();
3340
+ (0, react.useEffect)(() => {
3341
+ let live = true;
3342
+ const refresh = async () => {
3343
+ try {
3344
+ const response = await fetch("/api/task-board/state", {
3345
+ cache: "no-store",
3346
+ credentials: "same-origin"
3347
+ });
3348
+ if (!response.ok) return;
3349
+ const snapshot = await response.json();
3350
+ if (live) setPower(snapshot.power);
3351
+ } catch {}
3352
+ };
3353
+ refresh();
3354
+ const timer = setInterval(() => {
3355
+ refresh();
3356
+ }, 5e3);
3357
+ return () => {
3358
+ live = false;
3359
+ clearInterval(timer);
3360
+ };
3361
+ }, []);
3424
3362
  const fieldProps = {
3425
3363
  overriddenLabel: t("settings.overridden"),
3426
3364
  resetLabel: t("settings.reset"),
@@ -3431,43 +3369,166 @@ window.__ModuleLoader__.load({
3431
3369
  t,
3432
3370
  titleKey: "settings.title",
3433
3371
  descriptionKey: "settings.description",
3372
+ defaultOpen: false,
3434
3373
  state,
3435
3374
  onSave: props.save,
3436
3375
  onDiscard: props.discard,
3437
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3438
- id: "settings-task-board-enabled",
3439
- label: t("settings.enabled"),
3440
- hint: t("settings.enabledHint"),
3441
- inheritLabel: t("settings.inherit"),
3442
- onLabel: t("settings.on"),
3443
- offLabel: t("settings.off"),
3444
- ...fieldProps,
3445
- ...state.enabled,
3446
- onEdit: (text) => {
3447
- props.edit("enabled", text);
3448
- },
3449
- onReset: () => {
3450
- props.resetField("enabled");
3451
- }
3452
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3453
- id: "settings-task-board-announce",
3454
- label: t("settings.announceToAgent"),
3455
- hint: t("settings.announceToAgentHint"),
3456
- inheritLabel: t("settings.inherit"),
3457
- onLabel: t("settings.on"),
3458
- offLabel: t("settings.off"),
3459
- ...fieldProps,
3460
- ...state.announceToAgent,
3461
- onEdit: (text) => {
3462
- props.edit("announceToAgent", text);
3463
- },
3464
- onReset: () => {
3465
- props.resetField("announceToAgent");
3466
- }
3467
- })]
3376
+ children: [
3377
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3378
+ id: "settings-task-board-enabled",
3379
+ label: t("settings.enabled"),
3380
+ hint: t("settings.enabledHint"),
3381
+ inheritLabel: t("settings.inherit"),
3382
+ onLabel: t("settings.on"),
3383
+ offLabel: t("settings.off"),
3384
+ ...fieldProps,
3385
+ ...state.enabled,
3386
+ onEdit: (text) => {
3387
+ props.edit("enabled", text);
3388
+ },
3389
+ onReset: () => {
3390
+ props.resetField("enabled");
3391
+ }
3392
+ }),
3393
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3394
+ id: "settings-task-board-announce",
3395
+ label: t("settings.announceToAgent"),
3396
+ hint: t("settings.announceToAgentHint"),
3397
+ inheritLabel: t("settings.inherit"),
3398
+ onLabel: t("settings.on"),
3399
+ offLabel: t("settings.off"),
3400
+ ...fieldProps,
3401
+ ...state.announceToAgent,
3402
+ onEdit: (text) => {
3403
+ props.edit("announceToAgent", text);
3404
+ },
3405
+ onReset: () => {
3406
+ props.resetField("announceToAgent");
3407
+ }
3408
+ }),
3409
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
3410
+ id: "settings-task-board-prevent-idle-sleep",
3411
+ label: t("settings.preventIdleSleep"),
3412
+ hint: t("settings.preventIdleSleepHint"),
3413
+ inheritLabel: t("settings.inherit"),
3414
+ onLabel: t("settings.on"),
3415
+ offLabel: t("settings.off"),
3416
+ ...fieldProps,
3417
+ ...state.preventIdleSleep,
3418
+ onEdit: (text) => {
3419
+ props.edit("preventIdleSleep", text);
3420
+ },
3421
+ onReset: () => {
3422
+ props.resetField("preventIdleSleep");
3423
+ }
3424
+ }),
3425
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("settings.powerStatus", {
3426
+ platform: power?.platform ?? t("settings.powerUnknown"),
3427
+ phase: power?.phase ?? t("settings.powerUnknown"),
3428
+ running: String(power?.runningSessions ?? 0),
3429
+ schedules: String(power?.armedSchedules ?? 0)
3430
+ }) }),
3431
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("settings.powerBoundary") }),
3432
+ power?.lastError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("settings.powerError", { error: power.lastError }) })
3433
+ ]
3468
3434
  });
3469
3435
  }
3470
3436
  //#endregion
3437
+ //#region src/protocol.ts
3438
+ const TASK_BOARD_API_PREFIX = "/api/task-board";
3439
+ //#endregion
3440
+ //#region src/client/host-api.ts
3441
+ const IMPORT_MARKER = "dsh.taskBoard.v2.hostImported";
3442
+ const SOURCE_KEY = "dsh.taskBoard.v2.sourceId";
3443
+ const IMPORT_REQUEST_KEY = "dsh.taskBoard.v2.importRequestId";
3444
+ const REQUEST_TIMEOUT_MS = 15e3;
3445
+ function uuid() {
3446
+ return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
3447
+ }
3448
+ async function readJson(response) {
3449
+ const body = await response.json();
3450
+ if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`);
3451
+ return body;
3452
+ }
3453
+ var HttpTaskBoardHostTransport = class {
3454
+ storage;
3455
+ constructor(storage = globalThis.localStorage) {
3456
+ this.storage = storage;
3457
+ }
3458
+ async bootstrap(legacy) {
3459
+ const initial = await this.state();
3460
+ const ledgerId = initial.scheduler.ledgerId;
3461
+ if (legacy.length > 0 && ledgerId !== void 0 && this.storage?.getItem(IMPORT_MARKER) !== ledgerId) {
3462
+ let sourceId = this.storage?.getItem(SOURCE_KEY);
3463
+ if (sourceId === null || sourceId === void 0 || sourceId === "") {
3464
+ sourceId = uuid();
3465
+ this.storage?.setItem(SOURCE_KEY, sourceId);
3466
+ }
3467
+ let requestId = this.storage?.getItem(IMPORT_REQUEST_KEY);
3468
+ if (requestId === null || requestId === void 0 || requestId === "") {
3469
+ requestId = uuid();
3470
+ this.storage?.setItem(IMPORT_REQUEST_KEY, requestId);
3471
+ }
3472
+ const snapshot = await this.post(requestId, {
3473
+ kind: "import",
3474
+ sourceId,
3475
+ tasks: [...legacy]
3476
+ });
3477
+ this.storage?.setItem(IMPORT_MARKER, snapshot.scheduler.ledgerId ?? ledgerId);
3478
+ return snapshot;
3479
+ }
3480
+ return initial;
3481
+ }
3482
+ async state() {
3483
+ return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: "no-store" });
3484
+ }
3485
+ async action(action) {
3486
+ return await this.post(uuid(), action);
3487
+ }
3488
+ async post(requestId, action) {
3489
+ const envelope = {
3490
+ requestId,
3491
+ action
3492
+ };
3493
+ return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
3494
+ method: "POST",
3495
+ headers: { "content-type": "application/json" },
3496
+ body: JSON.stringify(envelope)
3497
+ });
3498
+ }
3499
+ async request(url, init) {
3500
+ const controller = new AbortController();
3501
+ const timeout = globalThis.setTimeout(() => {
3502
+ controller.abort();
3503
+ }, REQUEST_TIMEOUT_MS);
3504
+ try {
3505
+ return await readJson(await fetch(url, {
3506
+ ...init,
3507
+ signal: controller.signal
3508
+ }));
3509
+ } catch (error) {
3510
+ if (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s`);
3511
+ throw error;
3512
+ } finally {
3513
+ globalThis.clearTimeout(timeout);
3514
+ }
3515
+ }
3516
+ subscribe(listener) {
3517
+ const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`);
3518
+ events.onmessage = () => {
3519
+ listener();
3520
+ };
3521
+ const onVisible = () => {
3522
+ if (document.visibilityState === "visible") listener();
3523
+ };
3524
+ document.addEventListener("visibilitychange", onVisible);
3525
+ return () => {
3526
+ document.removeEventListener("visibilitychange", onVisible);
3527
+ events.close();
3528
+ };
3529
+ }
3530
+ };
3531
+ //#endregion
3471
3532
  //#region src/client/index.ts
3472
3533
  /** Locale namespace this plugin owns. */
3473
3534
  const NS = "task-board";
@@ -3512,85 +3573,18 @@ window.__ModuleLoader__.load({
3512
3573
  let uiDisposer;
3513
3574
  const mountUi = () => {
3514
3575
  if (uiDisposer !== void 0) return;
3515
- const sessions = ctx.sessions;
3516
- const workspaces = ctx.workspaces;
3576
+ const sessions = ctx.get("sessions");
3577
+ const workspaces = ctx.get("workspaces");
3517
3578
  const connection = ctx.get("connection");
3518
3579
  const controller = new BoardController({
3519
3580
  store: new LocalStorageTaskStore(),
3520
- exec: new ExecutionService({
3521
- sessions: {
3522
- list: sessions.list,
3523
- binding: (id) => {
3524
- const binding = sessions.binding(id);
3525
- if (binding === void 0) return void 0;
3526
- const { session } = binding;
3527
- return { session: {
3528
- rename: (title) => session.rename(title),
3529
- prompt: (content, mode) => session.prompt(content, mode).then((result) => result.ok ? { ok: true } : {
3530
- ok: false,
3531
- error: result.error
3532
- }),
3533
- command: (line) => session.command(line).then((result) => result.ok ? {
3534
- ok: true,
3535
- matched: result.value.matched
3536
- } : {
3537
- ok: false,
3538
- error: result.error
3539
- }),
3540
- getSnapshot: () => session.getSnapshot(),
3541
- subscribe: (fn) => session.subscribe(fn)
3542
- } };
3543
- },
3544
- noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId, agentPreset)
3545
- },
3546
- workspaces: {
3547
- list: workspaces.list,
3548
- connectWorkspace: (id) => workspaces.connectWorkspace(id)
3549
- },
3550
- presets: { select: async (sessionId, agentPreset) => {
3551
- try {
3552
- const response = await connection.api.agentPresets.select({
3553
- sessionId,
3554
- agentPreset
3555
- });
3556
- return response.result.ok ? { ok: true } : {
3557
- ok: false,
3558
- error: response.result.error
3559
- };
3560
- } catch (error) {
3561
- return {
3562
- ok: false,
3563
- error
3564
- };
3565
- }
3566
- } },
3567
- history: { loadTail: async (sessionId) => {
3568
- const response = await connection.api.sessions.history({
3569
- sessionId,
3570
- maxMessages: 20
3571
- });
3572
- return response.result.ok ? { events: response.result.value.events.map((entry) => entry.event) } : void 0;
3573
- } }
3574
- }),
3581
+ transport: new HttpTaskBoardHostTransport(),
3575
3582
  sessions: {
3576
3583
  list: sessions.list,
3577
3584
  open: (id) => sessions.open(id)
3578
3585
  }
3579
3586
  });
3580
3587
  controller.start();
3581
- const scheduler = new SchedulerService({
3582
- tasks: () => controller.getSnapshot().tasks,
3583
- refresh: () => controller.reloadFromStore(),
3584
- now: () => Date.now(),
3585
- runTask: (id) => controller.runTask(id),
3586
- applySchedule: (id, nextRunAt, lastTriggeredAt) => controller.applyScheduleNextRun(id, nextRunAt, lastTriggeredAt),
3587
- ready: () => sessions.list.getSnapshot().phase === "ready",
3588
- environment: {
3589
- addEventListener: (type, listener) => document.addEventListener(type, listener),
3590
- removeEventListener: (type, listener) => document.removeEventListener(type, listener)
3591
- }
3592
- });
3593
- scheduler.start();
3594
3588
  const disposers = [];
3595
3589
  const pushWorkspaceOptions = () => {
3596
3590
  const snapshot = workspaces.list.getSnapshot();
@@ -3628,7 +3622,6 @@ window.__ModuleLoader__.load({
3628
3622
  }
3629
3623
  uiDisposer = () => {
3630
3624
  for (const dispose of disposers.splice(0)) dispose();
3631
- scheduler.dispose();
3632
3625
  controller.dispose();
3633
3626
  uiDisposer = void 0;
3634
3627
  };