@linxin666/dsh-client-ui-task-board 0.2.8 → 0.3.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.
package/lib/index.js CHANGED
@@ -204,8 +204,19 @@ function resolveDshHome(env = process.env, home = homedir()) {
204
204
  function dshHome() {
205
205
  return resolveDshHome();
206
206
  }
207
- //#endregion
208
- //#region src/core/tasks.ts
207
+ /**
208
+ * Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
209
+ * most recent last. A running (unsettled) execution is never trimmed: the
210
+ * Host monitor and restart recovery depend on the active record, and a task
211
+ * cannot start a new run while one is still open.
212
+ */
213
+ function retainRecentExecutions(executions) {
214
+ if (executions.length <= 20) return [...executions];
215
+ const open = executions.filter((execution) => execution.endedAt === void 0);
216
+ const settled = executions.filter((execution) => execution.endedAt !== void 0);
217
+ const keepSettled = Math.max(20 - open.length, 0);
218
+ return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
219
+ }
209
220
  /** Statuses a settled task may be archived from. */
210
221
  const ARCHIVABLE_STATUSES = ["done", "failed"];
211
222
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
@@ -307,7 +318,7 @@ function startExecution(task, now, executionId) {
307
318
  ...task,
308
319
  status: "running",
309
320
  updatedAt: now,
310
- executions: [...task.executions, execution]
321
+ executions: retainRecentExecutions([...task.executions, execution])
311
322
  },
312
323
  execution
313
324
  };
@@ -1014,9 +1025,10 @@ function mergeTask(a, b) {
1014
1025
  const previous = byId.get(entry.id);
1015
1026
  byId.set(entry.id, previous === void 0 ? entry : betterExecution(previous, entry));
1016
1027
  }
1028
+ const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt);
1017
1029
  return {
1018
1030
  ...newer,
1019
- executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
1031
+ executions: retainRecentExecutions(executions)
1020
1032
  };
1021
1033
  }
1022
1034
  function parseHostTasks(values) {
@@ -1087,6 +1099,52 @@ var HostTaskLedger = class {
1087
1099
  scheduler
1088
1100
  };
1089
1101
  }
1102
+ /**
1103
+ * Runtime-only projection for the 5 s Host poll. It copies just primitive
1104
+ * identifiers and timestamps, never the complete task/execution history or
1105
+ * an authoritative mutable object from the ledger.
1106
+ */
1107
+ runtimeView() {
1108
+ let armedSchedules = 0;
1109
+ const openExecutions = [];
1110
+ for (const task of this.document.tasks) {
1111
+ if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
1112
+ for (const execution of task.executions) {
1113
+ if (execution.endedAt !== void 0) continue;
1114
+ openExecutions.push({
1115
+ taskId: task.id,
1116
+ executionId: execution.id,
1117
+ sessionId: execution.sessionId,
1118
+ startedAt: execution.startedAt
1119
+ });
1120
+ }
1121
+ }
1122
+ return {
1123
+ armedSchedules,
1124
+ openExecutions
1125
+ };
1126
+ }
1127
+ /** Count armed, non-archived schedules without cloning task histories. */
1128
+ armedScheduleCount() {
1129
+ let count = 0;
1130
+ for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
1131
+ return count;
1132
+ }
1133
+ /** Return value-only references for schedules due at the supplied Host time. */
1134
+ dueSchedules(now) {
1135
+ const due = [];
1136
+ for (const task of this.document.tasks) {
1137
+ if (task.archivedAt !== void 0) continue;
1138
+ const schedule = task.schedule;
1139
+ if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
1140
+ due.push({
1141
+ taskId: task.id,
1142
+ cron: schedule.cron,
1143
+ nextRunAt: schedule.nextRunAt
1144
+ });
1145
+ }
1146
+ return due;
1147
+ }
1090
1148
  subscribe(listener) {
1091
1149
  this.listeners.add(listener);
1092
1150
  return () => {
@@ -1314,7 +1372,10 @@ var HostTaskLedger = class {
1314
1372
  try {
1315
1373
  const parsed = JSON.parse(readFileSync(this.file, "utf8"));
1316
1374
  if (parsed.schemaVersion !== 2 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
1317
- const tasks = parseHostTasks(parsed.tasks);
1375
+ const tasks = parseHostTasks(parsed.tasks).map((task) => ({
1376
+ ...task,
1377
+ executions: retainRecentExecutions(task.executions)
1378
+ }));
1318
1379
  const invalidScheduleIds = parsed.tasks.flatMap((value) => {
1319
1380
  if (typeof value !== "object" || value === null) return [];
1320
1381
  const row = value;
@@ -2062,24 +2123,33 @@ var TaskBoardHostService = class {
2062
2123
  }
2063
2124
  async pollSessions() {
2064
2125
  if (this.disposed) return;
2065
- if (!this.active && !this.hasOpenExecutions()) return;
2126
+ if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return;
2066
2127
  const running = await this.runner.listRunning();
2067
2128
  const previous = this.power.snapshot();
2129
+ if (!running.known) {
2130
+ this.power.updateReasons({
2131
+ runningSessions: previous.runningSessions,
2132
+ armedSchedules: this.ledger.armedScheduleCount(),
2133
+ sessionStateKnown: false
2134
+ });
2135
+ return;
2136
+ }
2137
+ const runtime = this.ledger.runtimeView();
2068
2138
  this.power.updateReasons({
2069
- runningSessions: running.known ? running.count : previous.runningSessions,
2070
- armedSchedules: this.armedSchedules(),
2071
- sessionStateKnown: running.known
2139
+ runningSessions: running.count,
2140
+ armedSchedules: runtime.armedSchedules,
2141
+ sessionStateKnown: true
2072
2142
  });
2073
- if (running.known) await this.reconcileExecutions(running.items);
2143
+ await this.reconcileExecutions(running.items, runtime.openExecutions);
2074
2144
  }
2075
2145
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
2076
- async reconcileExecutions(sessions) {
2077
- for (const task of this.ledger.state().tasks) for (const execution of task.executions) {
2078
- if (execution.sessionId === void 0 || execution.endedAt !== void 0) continue;
2146
+ async reconcileExecutions(sessions, executions) {
2147
+ for (const execution of executions) {
2148
+ if (execution.sessionId === void 0) continue;
2079
2149
  try {
2080
2150
  const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
2081
2151
  if (result.outcome === "pending") continue;
2082
- this.ledger.settle(task.id, execution.id, result.outcome, "error" in result ? result.error : void 0);
2152
+ this.ledger.settle(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
2083
2153
  } catch {}
2084
2154
  }
2085
2155
  }
@@ -2093,20 +2163,14 @@ var TaskBoardHostService = class {
2093
2163
  this.ledger.skipMissed(now);
2094
2164
  return;
2095
2165
  }
2096
- for (const task of this.ledger.state().tasks) {
2097
- if (task.archivedAt !== void 0) continue;
2098
- const schedule = task.schedule;
2099
- if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
2166
+ for (const schedule of this.ledger.dueSchedules(now)) {
2100
2167
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
2101
- const opened = this.ledger.openScheduled(task.id, next, now);
2168
+ const opened = this.ledger.openScheduled(schedule.taskId, next, now);
2102
2169
  if (opened !== void 0) this.scheduleLaunch(opened);
2103
2170
  }
2104
2171
  }
2105
2172
  armedSchedules() {
2106
- return this.ledger.state().tasks.filter((task) => task.archivedAt === void 0 && task.schedule?.enabled === true).length;
2107
- }
2108
- hasOpenExecutions() {
2109
- return this.ledger.state().tasks.some((task) => task.executions.some((execution) => execution.endedAt === void 0));
2173
+ return this.ledger.armedScheduleCount();
2110
2174
  }
2111
2175
  scheduleLaunch(opened) {
2112
2176
  this.launch(opened).catch((error) => {
@@ -2145,6 +2209,26 @@ var TaskBoardHostService = class {
2145
2209
  }
2146
2210
  };
2147
2211
  //#endregion
2212
+ //#region src/http.ts
2213
+ /** Family-default JSON response headers; callers may append or override. */
2214
+ const JSON_HEADERS = {
2215
+ "content-type": "application/json; charset=utf-8",
2216
+ "referrer-policy": "no-referrer"
2217
+ };
2218
+ /**
2219
+ * Write one JSON response. Default headers are the family defaults
2220
+ * (content-type and referrer-policy); caller headers are appended or
2221
+ * override them.
2222
+ */
2223
+ function writeJson(res, status, body, headers = {}) {
2224
+ const payload = JSON.stringify(body);
2225
+ res.writeHead(status, {
2226
+ ...JSON_HEADERS,
2227
+ ...headers
2228
+ });
2229
+ res.end(payload);
2230
+ }
2231
+ //#endregion
2148
2232
  //#region src/loopback.ts
2149
2233
  /** IPv4 127/8 predicate (four decimal octets, first == 127). */
2150
2234
  function isIPv4Loopback(v4) {
@@ -2196,13 +2280,6 @@ const IMPORT_LIMIT = 2 * 1024 * 1024;
2196
2280
  const HEARTBEAT_MS = 15e3;
2197
2281
  /** Header replaced by an authenticated same-host reverse proxy. */
2198
2282
  const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
2199
- function json(res, status, body) {
2200
- res.writeHead(status, {
2201
- "content-type": "application/json; charset=utf-8",
2202
- "cache-control": "no-store"
2203
- });
2204
- res.end(JSON.stringify(body));
2205
- }
2206
2283
  function parseAuthority(authority) {
2207
2284
  if (authority.trim() !== authority) return void 0;
2208
2285
  const match = authority.startsWith("[") ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority);
@@ -2297,10 +2374,10 @@ function makeTaskBoardRoutes(service, access = {}) {
2297
2374
  const resolvedAccess = resolveAccess(access);
2298
2375
  const guard = (req, res) => {
2299
2376
  if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true;
2300
- json(res, 403, {
2377
+ writeJson(res, 403, {
2301
2378
  ok: false,
2302
2379
  error: "forbidden"
2303
- });
2380
+ }, { "cache-control": "no-store" });
2304
2381
  return false;
2305
2382
  };
2306
2383
  return [
@@ -2308,45 +2385,45 @@ function makeTaskBoardRoutes(service, access = {}) {
2308
2385
  kind: "exact",
2309
2386
  path: `${TASK_BOARD_API_PREFIX}/state`,
2310
2387
  handler: (req, res) => {
2311
- if (req.method !== "GET") return json(res, 405, {
2388
+ if (req.method !== "GET") return writeJson(res, 405, {
2312
2389
  ok: false,
2313
2390
  error: "method-not-allowed"
2314
- });
2391
+ }, { "cache-control": "no-store" });
2315
2392
  if (!guard(req, res)) return;
2316
- json(res, 200, service.snapshot());
2393
+ writeJson(res, 200, service.snapshot(), { "cache-control": "no-store" });
2317
2394
  }
2318
2395
  },
2319
2396
  {
2320
2397
  kind: "exact",
2321
2398
  path: `${TASK_BOARD_API_PREFIX}/action`,
2322
2399
  handler: async (req, res) => {
2323
- if (req.method !== "POST") return json(res, 405, {
2400
+ if (req.method !== "POST") return writeJson(res, 405, {
2324
2401
  ok: false,
2325
2402
  error: "method-not-allowed"
2326
- });
2403
+ }, { "cache-control": "no-store" });
2327
2404
  if (!guard(req, res)) return;
2328
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return json(res, 415, {
2405
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return writeJson(res, 415, {
2329
2406
  ok: false,
2330
2407
  error: "json-required"
2331
- });
2408
+ }, { "cache-control": "no-store" });
2332
2409
  try {
2333
2410
  const body = await readBody(req);
2334
2411
  const parsed = parseActionEnvelope(body.value);
2335
- if (parsed === void 0) return json(res, 400, {
2412
+ if (parsed === void 0) return writeJson(res, 400, {
2336
2413
  ok: false,
2337
2414
  error: "invalid-action"
2338
- });
2339
- if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return json(res, 413, {
2415
+ }, { "cache-control": "no-store" });
2416
+ if (parsed.action.kind !== "import" && Buffer.byteLength(body.raw) > ACTION_LIMIT) return writeJson(res, 413, {
2340
2417
  ok: false,
2341
2418
  error: "body-too-large"
2342
- });
2343
- json(res, 200, service.apply(parsed.requestId, parsed.action));
2419
+ }, { "cache-control": "no-store" });
2420
+ writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { "cache-control": "no-store" });
2344
2421
  } catch (error) {
2345
2422
  const message = error instanceof Error ? error.message : String(error);
2346
- json(res, message === "body-too-large" ? 413 : 400, {
2423
+ writeJson(res, message === "body-too-large" ? 413 : 400, {
2347
2424
  ok: false,
2348
2425
  error: message
2349
- });
2426
+ }, { "cache-control": "no-store" });
2350
2427
  }
2351
2428
  }
2352
2429
  },
@@ -2,17 +2,15 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store/execution seams remain for v1 migration tests. It also
6
- * closes the board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. It also closes the
6
+ * board whenever the user navigates to a session.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
10
10
  * The per use-case domain transitions (create/update/delete/schedule) live in
11
11
  * dedicated modules under core/use-cases and are applied here; the controller
12
- * owns only the orchestration seam (state, persistence, notify, execution,
13
- * navigation, reconciliation).
12
+ * owns only the orchestration seam (state, persistence, notify, navigation).
14
13
  */
15
- import type { ExecutionService } from './execution.ts';
16
14
  import type { TaskStore } from './store.ts';
17
15
  import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
18
16
  import { type TaskUpdatePatch } from './use-cases/task-update.ts';
@@ -37,15 +35,11 @@ export interface SessionsControllerFace {
37
35
  /** Controller dependencies (all swappable in tests). */
38
36
  export interface ControllerDeps {
39
37
  store: TaskStore;
40
- /** Legacy browser execution seam used only by isolated v1 tests. */
41
- exec?: ExecutionService;
42
38
  sessions: SessionsControllerFace;
43
39
  /** Clock; defaults to Date.now. */
44
40
  now?: () => number;
45
41
  /** Id minting; defaults to a random-uuid. */
46
42
  uuid?: () => string;
47
- /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
48
- reconcileDebounceMs?: number;
49
43
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
50
44
  transport?: TaskBoardTransport;
51
45
  }
@@ -105,7 +99,7 @@ export declare class BoardController {
105
99
  private hostState;
106
100
  private remoteSubscribed;
107
101
  private remoteInitialization;
108
- /** @param deps - store, execution service, and the sessions navigation face. */
102
+ /** @param deps - store and the sessions navigation face. */
109
103
  constructor(deps: ControllerDeps);
110
104
  /** Load the persisted ledger and start the navigation/status subscriptions. */
111
105
  start(): void;
@@ -178,32 +172,17 @@ export declare class BoardController {
178
172
  */
179
173
  openSession(sessionId: string): void;
180
174
  /**
181
- * Execute a task for real: move it to 'running', open an execution record,
182
- * and hand off to the ExecutionService. A second call while the task is
183
- * already running is ignored.
175
+ * Request a Host execution for a task: the Host ledger owns the running
176
+ * transition, the execution record, and the settlement. A second call
177
+ * while the task is already running is ignored; without a Host transport
178
+ * the run is refused (returns false).
184
179
  */
185
180
  runTask(id: string): Promise<boolean>;
186
- /** Re-run a settled task: move it back to 'todo' first, then execute. */
181
+ /** Re-run a settled task through the Host (the Host replans and executes). */
187
182
  rerunTask(id: string): Promise<void>;
188
- private handleExecutionEvent;
189
- /** Reconcile running tasks and close the board when the user navigates. */
183
+ /** Close the board when the user navigates to another session. */
190
184
  private onSessionsChanged;
191
185
  private lastCurrent;
192
- /** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
193
- private readonly activeExecutionIds;
194
- /** Debounce timer for {@link reconcileRunningTasks}. */
195
- private reconcileTimer;
196
- /** Whether a reconcile pass is underway (single-flight guard). */
197
- private reconcileInFlight;
198
- /**
199
- * Debounce + single-flight trigger for the running-task reconciliation.
200
- * Session-list notifications arrive in bursts (one per session status
201
- * change); both guards together keep a burst from reading the history API
202
- * once per running task.
203
- */
204
- private scheduleReconcile;
205
- /** Settle tasks left 'running' whose sessions already finished. */
206
- private reconcileRunningTasks;
207
186
  private persistAndNotify;
208
187
  private commitRemote;
209
188
  private performRemote;
@@ -25,6 +25,19 @@ export interface ExecutionRecord {
25
25
  /** Human failure text when the run failed (prompt rejection or agent error). */
26
26
  error: string | undefined;
27
27
  }
28
+ /**
29
+ * Maximum number of execution records retained per task. Older settled runs
30
+ * are trimmed when a new execution starts so per-action ledger cost stays
31
+ * bounded regardless of how often a task ran before.
32
+ */
33
+ export declare const EXECUTION_HISTORY_LIMIT = 20;
34
+ /**
35
+ * Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
36
+ * most recent last. A running (unsettled) execution is never trimmed: the
37
+ * Host monitor and restart recovery depend on the active record, and a task
38
+ * cannot start a new run while one is still open.
39
+ */
40
+ export declare function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[];
28
41
  /**
29
42
  * A scheduled-run rule attached to a task. The Host scheduler triggers the
30
43
  * task when `nextRunAt` is due and persists the rule in the Host ledger.
@@ -55,7 +68,10 @@ export interface TaskRecord {
55
68
  createdAt: number;
56
69
  /** Last mutation instant (ms epoch). */
57
70
  updatedAt: number;
58
- /** Every execution attempt, most recent last. */
71
+ /**
72
+ * Execution history retained on the task, most recent last: the latest
73
+ * {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
74
+ */
59
75
  executions: ExecutionRecord[];
60
76
  /** Optional scheduled-run rule (absent on tasks without a schedule). */
61
77
  schedule?: ScheduleRule;
@@ -9,6 +9,24 @@ export interface OpenedRun {
9
9
  task: TaskRecord;
10
10
  execution: ExecutionRecord;
11
11
  }
12
+ /** Minimal value copy used by the Host session monitor. */
13
+ export interface OpenExecutionReference {
14
+ readonly taskId: string;
15
+ readonly executionId: string;
16
+ readonly sessionId: string | undefined;
17
+ readonly startedAt: number;
18
+ }
19
+ /** Minimal value copy used by the Host scheduler. */
20
+ export interface DueScheduleReference {
21
+ readonly taskId: string;
22
+ readonly cron: string;
23
+ readonly nextRunAt: number;
24
+ }
25
+ /** Derived runtime data for one session-poll pass. */
26
+ export interface LedgerRuntimeView {
27
+ readonly armedSchedules: number;
28
+ readonly openExecutions: readonly OpenExecutionReference[];
29
+ }
12
30
  /**
13
31
  * Best-effort single-letter process state ('R','S','D','Z',...) or undefined
14
32
  * when no probe is available on this platform. Linux reads /proc/<pid>/stat
@@ -36,6 +54,16 @@ export declare class HostTaskLedger {
36
54
  scheduler: TaskBoardSchedulerSnapshot;
37
55
  };
38
56
  state(): LedgerState;
57
+ /**
58
+ * Runtime-only projection for the 5 s Host poll. It copies just primitive
59
+ * identifiers and timestamps, never the complete task/execution history or
60
+ * an authoritative mutable object from the ledger.
61
+ */
62
+ runtimeView(): LedgerRuntimeView;
63
+ /** Count armed, non-archived schedules without cloning task histories. */
64
+ armedScheduleCount(): number;
65
+ /** Return value-only references for schedules due at the supplied Host time. */
66
+ dueSchedules(now: number): DueScheduleReference[];
39
67
  subscribe(listener: () => void): () => void;
40
68
  dispose(): void;
41
69
  applyRequest(requestId: string, action: TaskBoardAction): {
@@ -37,7 +37,6 @@ export declare class TaskBoardHostService {
37
37
  private reconcileExecutions;
38
38
  private tickSchedule;
39
39
  private armedSchedules;
40
- private hasOpenExecutions;
41
40
  private scheduleLaunch;
42
41
  private schedulePoll;
43
42
  private scheduleTick;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared JSON body/response helpers for the host route families: one strict
3
+ * bounded body reader, one lenient bounded body reader, one JSON object
4
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
6
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
7
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
8
+ *
9
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
10
+ * edit this shared source and re-run the sync instead of editing a copy.
11
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
12
+ * belong in the same change as its introduction.
13
+ * @module dsh-web-ui-shared/host/http
14
+ */
15
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
+ /**
17
+ * Strict bounded body reader: parse a request body of at most maxBytes as
18
+ * JSON.
19
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
20
+ * invalid or empty payload.
21
+ */
22
+ export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
23
+ /**
24
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
25
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
26
+ * Overflow destroys the request instead of draining the remainder (no drain
27
+ * call, matching the current repo-wide behavior); callers must not keep
28
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
29
+ * also yield null.
30
+ */
31
+ export declare function readJsonBody(req: IncomingMessage, opts?: {
32
+ maxBytes?: number;
33
+ objectOnly?: boolean;
34
+ }): Promise<unknown | null>;
35
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
36
+ export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
37
+ /**
38
+ * Write one JSON response. Default headers are the family defaults
39
+ * (content-type and referrer-policy); caller headers are appended or
40
+ * override them.
41
+ */
42
+ export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.2.8",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -52,9 +52,11 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
52
52
  return (
53
53
  <div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
54
54
  <header className={css.boardHeader}>
55
+ {/* Shared hook: dsh-web-ui-all offsets center-view back controls beside the collapsed mobile sidebar. */}
55
56
  <button
56
57
  type="button"
57
58
  className={`${css.ghostButton} ${css.backButton}`}
59
+ data-dsh-center-view-back=""
58
60
  aria-label={t('board.close')}
59
61
  onClick={() => { controller.closeBoard() }}
60
62
  >