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

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 (37) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +349 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +161 -31
  7. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
  9. package/lib/types/client/host-api.d.ts +3 -3
  10. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  11. package/lib/types/client/sidebar-entry.d.ts +8 -10
  12. package/lib/types/core/controller.d.ts +9 -2
  13. package/lib/types/core/schedule.d.ts +7 -0
  14. package/lib/types/host-ledger.d.ts +10 -0
  15. package/lib/types/host-routes.d.ts +3 -1
  16. package/lib/types/host-runner.d.ts +12 -1
  17. package/lib/types/host-service.d.ts +5 -1
  18. package/lib/types/protocol.d.ts +6 -0
  19. package/package.json +1 -2
  20. package/src/client/PluginSettingsCard.tsx +219 -18
  21. package/src/client/TaskBoardSettingsCard.tsx +10 -10
  22. package/src/client/board/TaskBoard.tsx +5 -5
  23. package/src/client/board/TaskCard.tsx +1 -0
  24. package/src/client/board/TaskDetail.tsx +1 -1
  25. package/src/client/board-mount.tsx +1 -0
  26. package/src/client/host-api.ts +12 -3
  27. package/src/client/settings-card.module.css +90 -1
  28. package/src/client/sidebar-entry-core.ts +208 -0
  29. package/src/client/sidebar-entry.ts +24 -144
  30. package/src/core/controller.ts +20 -3
  31. package/src/core/execution.ts +1 -2
  32. package/src/core/schedule.ts +57 -10
  33. package/src/host-ledger.ts +67 -6
  34. package/src/host-routes.ts +11 -3
  35. package/src/host-runner.ts +24 -6
  36. package/src/host-service.ts +24 -7
  37. package/src/protocol.ts +7 -0
package/lib/index.js CHANGED
@@ -51,19 +51,65 @@ function isValidCron(expr) {
51
51
  * can never match (for example `0 0 30 2 *`). The five-year horizon includes
52
52
  * a full leap cycle, so a valid February 29 schedule remains reachable from
53
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).
54
61
  */
55
62
  function nextRunAtMs(expr, fromMs) {
56
63
  const schedule = parseCron(expr);
57
64
  if (schedule === null) return void 0;
58
65
  if (!hasPossibleCalendarDay(schedule)) return void 0;
59
66
  const from = new Date(fromMs);
60
- const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0);
61
67
  const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
62
- while (scan.getTime() <= limitMs) {
63
- if (matches(schedule, scan)) return scan.getTime();
64
- scan.setMinutes(scan.getMinutes() + 1);
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;
65
103
  }
66
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
+ }
67
113
  /** Reject impossible month/day pairs without spending the multi-year scan. */
68
114
  function hasPossibleCalendarDay(schedule) {
69
115
  if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
@@ -122,11 +168,7 @@ function matches(schedule, date) {
122
168
  if (!schedule.minutes.has(date.getMinutes())) return false;
123
169
  if (!schedule.hours.has(date.getHours())) return false;
124
170
  if (!schedule.months.has(date.getMonth() + 1)) return false;
125
- const dayMatches = schedule.days.has(date.getDate());
126
- const weekdayMatches = schedule.weekdays.has(date.getDay());
127
- if (schedule.dayWildcard) return weekdayMatches;
128
- if (schedule.weekdayWildcard) return dayMatches;
129
- return dayMatches || weekdayMatches;
171
+ return dayCandidate(schedule, date);
130
172
  }
131
173
  function isDigits(value) {
132
174
  return /^\d+$/.test(value);
@@ -836,7 +878,7 @@ function mergeTask(a, b) {
836
878
  executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
837
879
  };
838
880
  }
839
- function parseHostTasks(values, now) {
881
+ function parseHostTasks(values) {
840
882
  const rawById = /* @__PURE__ */ new Map();
841
883
  for (const value of values) {
842
884
  if (typeof value !== "object" || value === null) continue;
@@ -868,11 +910,14 @@ var HostTaskLedger = class {
868
910
  lockFd;
869
911
  file;
870
912
  lockFile;
913
+ /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
914
+ schedulerFile;
871
915
  constructor(dir = join(dshHome(), "task-board"), now = Date.now) {
872
916
  this.now = now;
873
917
  mkdirSync(dir, { recursive: true });
874
918
  this.file = join(dir, "ledger-v2.json");
875
919
  this.lockFile = join(dir, "ledger-v2.lock");
920
+ this.schedulerFile = join(dir, "scheduler-v2.json");
876
921
  this.lockFd = this.acquireLock();
877
922
  try {
878
923
  this.document = this.load(dir);
@@ -885,14 +930,22 @@ var HostTaskLedger = class {
885
930
  throw error;
886
931
  }
887
932
  }
888
- state() {
933
+ /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
934
+ summary() {
889
935
  const { importedSources: _imports, ...scheduler } = this.document.scheduler;
890
936
  return {
891
937
  revision: this.document.revision,
892
- tasks: cloneTasks(this.document.tasks),
893
938
  scheduler: { ...scheduler }
894
939
  };
895
940
  }
941
+ state() {
942
+ const { revision, scheduler } = this.summary();
943
+ return {
944
+ revision,
945
+ tasks: cloneTasks(this.document.tasks),
946
+ scheduler
947
+ };
948
+ }
896
949
  subscribe(listener) {
897
950
  this.listeners.add(listener);
898
951
  return () => {
@@ -962,6 +1015,10 @@ var HostTaskLedger = class {
962
1015
  ...this.document.scheduler,
963
1016
  ...patch
964
1017
  };
1018
+ if (patch.lastTickAt !== void 0 && Object.keys(patch).every((key) => key === "lastTickAt")) {
1019
+ this.writeSchedulerSidecar();
1020
+ return;
1021
+ }
965
1022
  this.commit(false);
966
1023
  }
967
1024
  attachSession(taskId, executionId, sessionId) {
@@ -988,7 +1045,7 @@ var HostTaskLedger = class {
988
1045
  const sources = new Set(this.document.scheduler.importedSources ?? []);
989
1046
  if (sources.has(action.sourceId)) return { state: this.state() };
990
1047
  const invalidScheduleIds = action.tasks.filter((task) => task.schedule !== void 0 && !isValidCron(task.schedule.cron)).map((task) => task.id);
991
- const incoming = parseHostTasks(action.tasks, now);
1048
+ const incoming = parseHostTasks(action.tasks);
992
1049
  const merged = new Map(this.document.tasks.map((task) => [task.id, task]));
993
1050
  for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id), task) : task);
994
1051
  this.document.tasks = [...merged.values()];
@@ -1116,7 +1173,7 @@ var HostTaskLedger = class {
1116
1173
  try {
1117
1174
  const parsed = JSON.parse(readFileSync(this.file, "utf8"));
1118
1175
  if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
1119
- const tasks = parseHostTasks(parsed.tasks, this.now());
1176
+ const tasks = parseHostTasks(parsed.tasks);
1120
1177
  const invalidScheduleIds = parsed.tasks.flatMap((value) => {
1121
1178
  if (typeof value !== "object" || value === null) return [];
1122
1179
  const row = value;
@@ -1124,6 +1181,9 @@ var HostTaskLedger = class {
1124
1181
  const cron = row.schedule.cron;
1125
1182
  return typeof cron !== "string" || !isValidCron(cron) ? [typeof row.id === "string" ? row.id : "unknown"] : [];
1126
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;
1127
1187
  return {
1128
1188
  schemaVersion: 2,
1129
1189
  revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
@@ -1131,7 +1191,7 @@ var HostTaskLedger = class {
1131
1191
  scheduler: {
1132
1192
  timeZone: timeZone(),
1133
1193
  ledgerId: typeof parsed.scheduler?.ledgerId === "string" && parsed.scheduler.ledgerId !== "" ? parsed.scheduler.ledgerId : crypto.randomUUID(),
1134
- ...typeof parsed.scheduler?.lastTickAt === "number" ? { lastTickAt: parsed.scheduler.lastTickAt } : {},
1194
+ ...lastTickAt === void 0 ? {} : { lastTickAt },
1135
1195
  ...typeof parsed.scheduler?.error === "string" ? { error: parsed.scheduler.error } : {},
1136
1196
  ...invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}` } : {},
1137
1197
  ...Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter((x) => typeof x === "string") } : {}
@@ -1167,6 +1227,47 @@ var HostTaskLedger = class {
1167
1227
  fingerprint: request.fingerprint
1168
1228
  }));
1169
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
+ }
1170
1271
  commit(bumpRevision = true) {
1171
1272
  if (bumpRevision) this.document.revision += 1;
1172
1273
  mkdirSync(dirname(this.file), { recursive: true });
@@ -1319,16 +1420,28 @@ var HostExecutionRunner = class {
1319
1420
  const response = await this.api.sessions.list(request({}));
1320
1421
  return response.result.ok ? {
1321
1422
  known: true,
1322
- count: response.result.value.items.filter((item) => item.running).length
1423
+ count: response.result.value.items.filter((item) => item.running).length,
1424
+ items: response.result.value.items
1323
1425
  } : { known: false };
1324
1426
  } catch {
1325
1427
  return { known: false };
1326
1428
  }
1327
1429
  }
1328
- async inspect(sessionId, startedAt = 0) {
1329
- const sessions = await this.api.sessions.list(request({}));
1330
- if (!sessions.result.ok) return { outcome: "pending" };
1331
- const summary = sessions.result.value.items.find((item) => item.sessionId === sessionId);
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);
1332
1445
  if (summary === void 0) return {
1333
1446
  outcome: "cancelled",
1334
1447
  error: "execution session no longer exists"
@@ -1688,6 +1801,7 @@ var TaskBoardHostService = class {
1688
1801
  tickInFlight = false;
1689
1802
  active = true;
1690
1803
  preventIdleSleep = false;
1804
+ lastPowerJson = "";
1691
1805
  now;
1692
1806
  constructor(api, options = {}) {
1693
1807
  this.ledger = options.ledger ?? new HostTaskLedger();
@@ -1699,6 +1813,9 @@ var TaskBoardHostService = class {
1699
1813
  this.emit();
1700
1814
  });
1701
1815
  this.power.subscribe(() => {
1816
+ const json = JSON.stringify(this.power.snapshot());
1817
+ if (json === this.lastPowerJson) return;
1818
+ this.lastPowerJson = json;
1702
1819
  this.emit();
1703
1820
  });
1704
1821
  }
@@ -1743,6 +1860,15 @@ var TaskBoardHostService = class {
1743
1860
  power: this.power.snapshot()
1744
1861
  };
1745
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
+ }
1746
1872
  subscribe(listener) {
1747
1873
  this.listeners.add(listener);
1748
1874
  return () => {
@@ -1787,14 +1913,14 @@ var TaskBoardHostService = class {
1787
1913
  armedSchedules: this.armedSchedules(),
1788
1914
  sessionStateKnown: running.known
1789
1915
  });
1790
- if (running.known) await this.reconcileExecutions();
1791
- this.emit();
1916
+ if (running.known) await this.reconcileExecutions(running.items);
1792
1917
  }
1793
- async reconcileExecutions() {
1918
+ /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
1919
+ async reconcileExecutions(sessions) {
1794
1920
  for (const task of this.ledger.state().tasks) for (const execution of task.executions) {
1795
1921
  if (execution.sessionId === void 0 || execution.endedAt !== void 0) continue;
1796
1922
  try {
1797
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt);
1923
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
1798
1924
  if (result.outcome === "pending") continue;
1799
1925
  this.ledger.settle(task.id, execution.id, result.outcome, "error" in result ? result.error : void 0);
1800
1926
  } catch {}
@@ -1950,6 +2076,12 @@ function resolveAccess(access) {
1950
2076
  ...access.proxyToken === void 0 ? {} : { proxyToken: access.proxyToken }
1951
2077
  };
1952
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
+ */
1953
2085
  function browserSameOriginMarker(req) {
1954
2086
  return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
1955
2087
  }
@@ -1972,7 +2104,9 @@ function matchesToken(candidate, expected) {
1972
2104
  /**
1973
2105
  * Task-board route fence. Direct desktop access uses the repository-wide
1974
2106
  * loopback socket + Host guard and additionally requires a browser same-origin
1975
- * marker, so a bare local curl cannot exercise the agent control plane.
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).
1976
2110
  * Authenticated proxies must be explicitly allowlisted and replace the
1977
2111
  * internal token header after their own authentication step.
1978
2112
  */
@@ -2075,12 +2209,8 @@ function makeTaskBoardRoutes(service, access = {}) {
2075
2209
  connection: "keep-alive"
2076
2210
  });
2077
2211
  const push = () => {
2078
- const snapshot = service.snapshot();
2079
- res.write(`data: ${JSON.stringify({
2080
- revision: snapshot.revision,
2081
- scheduler: snapshot.scheduler,
2082
- power: snapshot.power
2083
- })}\n\n`);
2212
+ const payload = service.eventPayload();
2213
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
2084
2214
  };
2085
2215
  const unsubscribe = service.subscribe(push);
2086
2216
  const heartbeat = setInterval(() => {
@@ -88,6 +88,27 @@ export declare function ValueField(props: FieldProps & {
88
88
  /** Placeholder shown while the draft is empty. */
89
89
  placeholder?: string;
90
90
  }): import("react").JSX.Element;
91
+ interface SelectOption {
92
+ value: string;
93
+ label: string;
94
+ }
95
+ /**
96
+ * The shared dual-mode select control. While an appearance skin is active it
97
+ * renders the legacy native `<select>` untouched, so element-level skin
98
+ * selectors keep working; under the default appearance it renders a
99
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
100
+ * Staged cards reach it through BooleanField/ChoiceField; immediate-apply
101
+ * editors (the side-card prefs) bind it directly through onEdit.
102
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
103
+ */
104
+ export declare function SelectField(props: {
105
+ id: string;
106
+ options: ReadonlyArray<SelectOption>;
107
+ value: string;
108
+ disabled: boolean;
109
+ invalid: boolean;
110
+ onEdit: (text: string) => void;
111
+ }): import("react").JSX.Element;
91
112
  /** A staged boolean field: 继承 / 开 / 关. */
92
113
  export declare function BooleanField(props: FieldProps & {
93
114
  /** Copy for the inherit option. */
@@ -107,3 +128,4 @@ export declare function ChoiceField(props: FieldProps & {
107
128
  label: string;
108
129
  }>;
109
130
  }): import("react").JSX.Element;
131
+ export {};
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Task-board settings for availability, agent announcement, and optional Host
3
- * idle-sleep protection. Registers into the `settings.plugin.item` slot the
4
- * plugin-configuration section renders, bound to the `task-board` namespace.
3
+ * idle-sleep protection. Registers into the `web-ui.plugin.item` child slot
4
+ * the Web UI plugin group renders, bound to the `task-board` namespace.
5
5
  */
6
6
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
7
7
  import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
@@ -1,10 +1,10 @@
1
1
  import type { TaskRecord } from '../core/tasks.ts';
2
- import { type TaskBoardAction, type TaskBoardSnapshot } from '../protocol.ts';
2
+ import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from '../protocol.ts';
3
3
  export interface TaskBoardHostTransport {
4
4
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
5
5
  state(): Promise<TaskBoardSnapshot>;
6
6
  action(action: TaskBoardAction): Promise<TaskBoardSnapshot>;
7
- subscribe(listener: () => void): () => void;
7
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
8
8
  }
9
9
  export declare class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
10
10
  private readonly storage;
@@ -14,5 +14,5 @@ export declare class HttpTaskBoardHostTransport implements TaskBoardHostTranspor
14
14
  action(action: TaskBoardAction): Promise<TaskBoardSnapshot>;
15
15
  private post;
16
16
  private request;
17
- subscribe(listener: () => void): () => void;
17
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
18
18
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared sidebar entry injection core.
3
+ *
4
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
5
+ * so the entry row is injected between the shell's New Session button and the
6
+ * workspace browser. The injection self-heals: a MutationObserver watches the
7
+ * sidebar root and re-inserts the row whenever a React re-render displaces it
8
+ * (re-insertion happens in the same frame, before paint, so no flicker).
9
+ *
10
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
11
+ * reconciliation; the view it toggles is a separate root owned by the caller.
12
+ *
13
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
14
+ * edit the shared source and re-run the sync instead of editing a copy.
15
+ */
16
+ /** Per-package configuration for one sidebar entry row. */
17
+ export interface SidebarEntryOptions {
18
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
19
+ rowAttribute: string;
20
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
21
+ rowSelector: string;
22
+ /**
23
+ * L2 semantic-attribute plugin id (issue #506, enum table:
24
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
25
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
26
+ * leaves the row without semantic attributes.
27
+ */
28
+ plugin?: string;
29
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
30
+ icon: string;
31
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
32
+ css: Record<string, string>;
33
+ /** Localized row label (aria-label + visible text). */
34
+ label(): string;
35
+ /** Optional localized tooltip (title attribute). */
36
+ tooltip?(): string;
37
+ /** Click action (open/toggle the owning panel). */
38
+ onToggle(): void;
39
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
40
+ position: 'before' | 'after';
41
+ /**
42
+ * Selectors of the sibling plugin entry rows this package orders against
43
+ * (its own row included — the placement guard excludes a row that is
44
+ * already inside the root). Each package passes the same list it used
45
+ * before the consolidation so the rendered order stays stable.
46
+ */
47
+ familySelectors: readonly string[];
48
+ /** Optional active-state bridge; highlights the row while the panel is open. */
49
+ active?: {
50
+ subscribe(listener: () => void): () => void;
51
+ isOpen(): boolean;
52
+ };
53
+ }
54
+ /**
55
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
56
+ * on later React re-renders.
57
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
58
+ * @returns disposer removing the entry and its observers.
59
+ */
60
+ export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
@@ -1,17 +1,15 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
3
  *
4
4
  * dsh's sidebar shell exposes no slot an external plugin can register into
5
5
  * (`sidebar.workspaces` / `sidebar.settings` are single-occupant and already
6
- * taken), so — following the skin precedent of DOM-level extension — the
7
- * entry row is injected between the shell's New Session button and the
8
- * workspace browser. The injection self-heals: a MutationObserver watches the
9
- * sidebar root and re-inserts the row whenever a React re-render displaces it
10
- * (re-insertion happens in the same frame, before paint, so no flicker).
11
- *
12
- * The row is plain DOM (no React tree) so it can never disturb the shell's
13
- * reconciliation; the board view it toggles is a separate React root mounted
14
- * in the center column (see board-mount.ts).
6
+ * taken), so the entry row is injected between the shell's New Session button
7
+ * and the workspace browser. The DOM injection / self-healing / idempotency
8
+ * logic lives exactly once in shared/client/sidebar-entry-core.ts (synced
9
+ * copy); this wrapper supplies the task-board icon, copy, CSS module, and the
10
+ * board toggle. The row is plain DOM (no React tree); the board view it
11
+ * toggles is a separate React root mounted in the center column
12
+ * (see board-mount.ts).
15
13
  */
16
14
  import type { BoardController } from '../core/controller.ts';
17
15
  /** Stable data attribute identifying the injected entry row. */
@@ -16,12 +16,12 @@ import type { ExecutionService } from './execution.ts';
16
16
  import type { TaskStore } from './store.ts';
17
17
  import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
18
18
  import { type TaskUpdatePatch } from './use-cases/task-update.ts';
19
- import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts';
19
+ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts';
20
20
  export interface TaskBoardTransport {
21
21
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
22
22
  state(): Promise<TaskBoardSnapshot>;
23
23
  action(action: TaskBoardAction): Promise<TaskBoardSnapshot>;
24
- subscribe(listener: () => void): () => void;
24
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
25
25
  }
26
26
  /** The sessions face the controller needs for navigation awareness. */
27
27
  export interface SessionsControllerFace {
@@ -209,6 +209,13 @@ export declare class BoardController {
209
209
  private performRemote;
210
210
  private initializeRemote;
211
211
  private doInitializeRemote;
212
+ /**
213
+ * SSE frames carry revision/scheduler/power. When the revision matches the
214
+ * one already applied, apply the frame's scheduler/power in place and skip
215
+ * the full /state fetch; otherwise the 5 s heartbeat would re-clone and
216
+ * re-serialize the whole ledger per tab even while nothing changes.
217
+ */
218
+ private onRemoteEvent;
212
219
  private refreshRemote;
213
220
  private acceptRemote;
214
221
  private notify;
@@ -38,5 +38,12 @@ export declare function isValidCron(expr: string): boolean;
38
38
  * can never match (for example `0 0 30 2 *`). The five-year horizon includes
39
39
  * a full leap cycle, so a valid February 29 schedule remains reachable from
40
40
  * every non-leap year.
41
+ *
42
+ * Walks candidate year/month/day/hour/minute values straight from the parsed
43
+ * field sets instead of scanning every minute: a sparse expression such as
44
+ * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
45
+ * next leap day. Wall-clock field construction + the final `matches` re-check
46
+ * preserve the old minute scan's DST semantics exactly (nonexistent spring
47
+ * minutes normalize forward and the repeated fall-back hour is never visited).
41
48
  */
42
49
  export declare function nextRunAtMs(expr: string, fromMs: number): number | undefined;
@@ -18,7 +18,14 @@ export declare class HostTaskLedger {
18
18
  private lockFd;
19
19
  readonly file: string;
20
20
  readonly lockFile: string;
21
+ /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
22
+ readonly schedulerFile: string;
21
23
  constructor(dir?: string, now?: () => number);
24
+ /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
25
+ summary(): {
26
+ revision: number;
27
+ scheduler: TaskBoardSchedulerSnapshot;
28
+ };
22
29
  state(): LedgerState;
23
30
  subscribe(listener: () => void): () => void;
24
31
  dispose(): void;
@@ -36,6 +43,9 @@ export declare class HostTaskLedger {
36
43
  private reconcileInterruptedStarts;
37
44
  private load;
38
45
  private syncRecentRequests;
46
+ private readSchedulerSidecar;
47
+ /** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
48
+ private writeSchedulerSidecar;
39
49
  private commit;
40
50
  private notify;
41
51
  private acquireLock;
@@ -15,7 +15,9 @@ interface ResolvedRouteAccess {
15
15
  /**
16
16
  * Task-board route fence. Direct desktop access uses the repository-wide
17
17
  * loopback socket + Host guard and additionally requires a browser same-origin
18
- * marker, so a bare local curl cannot exercise the agent control plane.
18
+ * marker: a bare local curl without any browser signal cannot exercise the
19
+ * agent control plane (a forged Origin does pass the marker — it is a
20
+ * tripwire, the socket/Host/origin-equality checks carry the authority).
19
21
  * Authenticated proxies must be explicitly allowlisted and replace the
20
22
  * internal token header after their own authentication step.
21
23
  */
@@ -1,5 +1,9 @@
1
1
  import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
2
2
  import type { TaskRecord } from './core/tasks.ts';
3
+ /** One session-list row, extracted from the sessions.list RPC result. */
4
+ export type SessionSummary = Extract<Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'], {
5
+ ok: true;
6
+ }>['value']['items'][number];
3
7
  export type ExecutionInspection = {
4
8
  outcome: 'pending';
5
9
  } | {
@@ -23,8 +27,15 @@ export declare class HostExecutionRunner {
23
27
  listRunning(): Promise<{
24
28
  known: true;
25
29
  count: number;
30
+ items: SessionSummary[];
26
31
  } | {
27
32
  known: false;
28
33
  }>;
29
- inspect(sessionId: string, startedAt?: number): Promise<ExecutionInspection>;
34
+ /**
35
+ * Resolve one execution's outcome. The caller may pass the session list it
36
+ * already fetched this poll tick; otherwise inspect lists sessions itself.
37
+ * Sharing the list keeps a poll with E open executions at one list RPC
38
+ * instead of 1 + E.
39
+ */
40
+ inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection>;
30
41
  }
@@ -2,7 +2,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
2
2
  import { HostTaskLedger } from './host-ledger.ts';
3
3
  import { HostExecutionRunner } from './host-runner.ts';
4
4
  import { PowerInhibitor } from './power-inhibitor.ts';
5
- import type { TaskBoardAction, TaskBoardSnapshot } from './protocol.ts';
5
+ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts';
6
6
  export declare class TaskBoardHostService {
7
7
  readonly ledger: HostTaskLedger;
8
8
  readonly runner: HostExecutionRunner;
@@ -15,6 +15,7 @@ export declare class TaskBoardHostService {
15
15
  private tickInFlight;
16
16
  private active;
17
17
  private preventIdleSleep;
18
+ private lastPowerJson;
18
19
  private readonly now;
19
20
  constructor(api: ApiProxy, options?: {
20
21
  ledger?: HostTaskLedger;
@@ -24,11 +25,14 @@ export declare class TaskBoardHostService {
24
25
  start(): void;
25
26
  setConfiguration(active: boolean, preventIdleSleep: boolean): void;
26
27
  snapshot(): TaskBoardSnapshot;
28
+ /** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
29
+ eventPayload(): TaskBoardEventPayload;
27
30
  subscribe(listener: () => void): () => void;
28
31
  apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot;
29
32
  dispose(): void;
30
33
  private launch;
31
34
  private pollSessions;
35
+ /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
32
36
  private reconcileExecutions;
33
37
  private tickSchedule;
34
38
  private armedSchedules;
@@ -26,6 +26,12 @@ export interface TaskBoardSnapshot {
26
26
  scheduler: TaskBoardSchedulerSnapshot;
27
27
  power: TaskBoardPowerSnapshot;
28
28
  }
29
+ /** SSE event frame: revision/scheduler/power only, never the task list. */
30
+ export interface TaskBoardEventPayload {
31
+ revision: number;
32
+ scheduler: TaskBoardSchedulerSnapshot;
33
+ power: TaskBoardPowerSnapshot;
34
+ }
29
35
  export type TaskBoardAction = {
30
36
  kind: 'import';
31
37
  sourceId: string;