@linxin666/dsh-client-ui-task-board 0.3.2 → 0.3.4

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.
@@ -0,0 +1,105 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/telemetry.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Anonymous install telemetry shared by the DSH Web UI family plugins.
4
+ *
5
+ * Once per UTC day per browser, each wired plugin sends one heartbeat to
6
+ * the dsh-market edge API listing its package name (and version when known).
7
+ * The payload carries no conversation data and no identifiers beyond a random
8
+ * UUID generated in localStorage; the server hashes it with a deployment salt
9
+ * before storage and never persists IP addresses. Sends are fire-and-forget:
10
+ * failures stay silent and simply retry on a later mount or day. The system
11
+ * is documented in docs/telemetry.md.
12
+ */
13
+
14
+ export type TelemetryChannel = 'market' | 'npm' | 'unknown'
15
+
16
+ export interface TelemetryItem {
17
+ /** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
18
+ name: string
19
+ /** Installed version when known; omitted otherwise. */
20
+ version?: string
21
+ /** Install channel when determinable (market = Workshop install). */
22
+ channel?: TelemetryChannel
23
+ }
24
+
25
+ const VISITOR_KEY = 'dsh-web-ui-telemetry-visitor'
26
+ const DAY_KEY_PREFIX = 'dsh-web-ui-telemetry-day:'
27
+ const ENDPOINT = 'https://dsh-market.com/api/telemetry/event'
28
+
29
+ // Baked into each client bundle by shared/tsdown.client.ts (the package's own
30
+ // package.json version at build time). Undefined in dev/test builds.
31
+ declare const __DSH_PKG_VERSION__: string | undefined
32
+
33
+ /** The building package's version, when the bundle carries it. */
34
+ function bakedVersion(): string | undefined {
35
+ try {
36
+ return typeof __DSH_PKG_VERSION__ === 'string' && __DSH_PKG_VERSION__ !== ''
37
+ ? __DSH_PKG_VERSION__
38
+ : undefined
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ /** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
45
+ function visitorId(): string | null {
46
+ try {
47
+ const existing = localStorage.getItem(VISITOR_KEY)
48
+ if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing
49
+ const fresh = crypto.randomUUID().replaceAll('-', '')
50
+ localStorage.setItem(VISITOR_KEY, fresh)
51
+ return fresh
52
+ } catch {
53
+ // Storage unavailable (privacy mode, sandboxed frame): report nothing.
54
+ return null
55
+ }
56
+ }
57
+
58
+ /** Drop stale per-day dedup keys so localStorage does not grow forever. */
59
+ function pruneDayKeys(today: string): void {
60
+ try {
61
+ for (let index = localStorage.length - 1; index >= 0; index -= 1) {
62
+ const key = localStorage.key(index)
63
+ if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) {
64
+ localStorage.removeItem(key)
65
+ }
66
+ }
67
+ } catch { /* best effort */ }
68
+ }
69
+
70
+ /**
71
+ * Fire the daily heartbeat for the given items at most once per UTC day per
72
+ * browser. Never throws and never blocks the caller. Items without an explicit
73
+ * version inherit the bundle's baked build version.
74
+ */
75
+ export function reportDailyHeartbeat(items: readonly TelemetryItem[]): void {
76
+ try {
77
+ if (items.length === 0) return
78
+ const today = new Date().toISOString().slice(0, 10)
79
+ // Automation contexts (CDP-driven QA browsers, fresh isolated profiles)
80
+ // mint a new visitor id per run and would inflate instance counts.
81
+ if (navigator.webdriver) return
82
+ if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return
83
+ const visitor = visitorId()
84
+ if (visitor === null) return
85
+ pruneDayKeys(today)
86
+ const payloadItems = items.map((item) => {
87
+ const out: Record<string, string> = { name: item.name }
88
+ const version = item.version ?? bakedVersion()
89
+ if (version !== undefined) out.version = version
90
+ if (item.channel !== undefined) out.channel = item.channel
91
+ return out
92
+ })
93
+ const body = JSON.stringify({ kind: 'heartbeat', visitor, items: payloadItems })
94
+ void fetch(ENDPOINT, {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body,
98
+ keepalive: true,
99
+ }).then((response) => {
100
+ // Mark the day only after an accepted send, so offline browsers retry
101
+ // on a later mount instead of going dark until tomorrow.
102
+ if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, '1')
103
+ }).catch(() => { /* unreachable endpoint: stay silent */ })
104
+ } catch { /* never break the host UI over telemetry */ }
105
+ }
@@ -273,13 +273,21 @@ export class BoardController {
273
273
  : undefined
274
274
  }
275
275
 
276
- updateTask(id: string, patch: TaskUpdatePatch): void {
276
+ /**
277
+ * Apply an editable-field patch (task content + execution targets).
278
+ * Host-backed: the Host ledger owns the fail-closed checks (the content of
279
+ * an executed task is read-only) and confirms the mutation, so the resolved
280
+ * value reflects whether the Host accepted it; the legacy in-memory path
281
+ * applies and persists synchronously.
282
+ * @returns true when the patch was accepted by the authority.
283
+ */
284
+ async updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean> {
277
285
  if (this.deps.transport !== undefined) {
278
- void this.commitRemote({ kind: 'update', taskId: id, patch }, id)
279
- return
286
+ return await this.commitRemote({ kind: 'update', taskId: id, patch }, id)
280
287
  }
281
288
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
282
289
  this.persistAndNotify()
290
+ return true
283
291
  }
284
292
 
285
293
  /**
@@ -4,15 +4,38 @@
4
4
  * fresh updatedAt. Pure ledger transition (no persistence or notify — the
5
5
  * controller orchestrates those).
6
6
  *
7
- * An explicit `undefined` in the patch clears the field (the task falls
8
- * back to the runtime default); an unknown permission string is ignored so
9
- * stale UI can never persist a value the execution service rejects.
7
+ * An explicit `undefined` in the patch clears an execution-target field
8
+ * (the task falls back to the runtime default); content fields are trimmed
9
+ * and never cleared. An unknown permission string is ignored so stale UI
10
+ * can never persist a value the execution service rejects.
10
11
  */
11
12
  import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
12
13
 
13
14
  /** Editable fields on a task (the update patch surface). */
14
15
  export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
15
16
 
17
+ /**
18
+ * The fields that edit the task's content (what the user reads and what the
19
+ * next execution sends). Unlike the execution targets they stay editable only
20
+ * while the task has never started executing — after the first run the
21
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
22
+ */
23
+ export const TASK_CONTENT_FIELDS = ['title', 'description', 'prompt'] as const
24
+
25
+ /** Whether an update patch touches any task-content field. */
26
+ export function hasContentPatch(patch: TaskUpdatePatch): boolean {
27
+ return (TASK_CONTENT_FIELDS as readonly string[]).some(field => field in patch)
28
+ }
29
+
30
+ /**
31
+ * Whether a task's content may still be edited: the task must be on-board
32
+ * (not archived) and must never have started executing. Fail-closed: a
33
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
34
+ */
35
+ export function canEditTaskContent(task: TaskRecord): boolean {
36
+ return task.archivedAt === undefined && task.status !== 'running' && task.executions.length === 0
37
+ }
38
+
16
39
  /** Keep an unknown permission string from entering the ledger. */
17
40
  function normalizePermission(
18
41
  current: TaskPermission | undefined,
@@ -42,6 +65,13 @@ export function applyUpdateTask(
42
65
  const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
43
66
  const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
44
67
  const next: TaskRecord = { ...task, ...patch, updatedAt: now }
68
+ // Content fields normalize like creation does (trimmed); an explicit
69
+ // undefined keeps the current value — content cannot be cleared.
70
+ for (const field of TASK_CONTENT_FIELDS) {
71
+ if (!(field in patch)) continue
72
+ const value = patch[field]
73
+ next[field] = value === undefined ? task[field] : value.trim()
74
+ }
45
75
  if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
46
76
  if (mode !== undefined || 'mode' in patch) next.mode = mode
47
77
  if (permission !== undefined || 'permission' in patch) next.permission = permission
package/src/dsh-home.ts CHANGED
@@ -7,11 +7,14 @@
7
7
 
8
8
  import { homedir } from 'node:os'
9
9
  import { isAbsolute, join } from 'node:path'
10
+ import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
10
11
 
11
12
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
13
  export function expandHome(path: string, home: string = homedir()): string {
14
+ const isPosix = home.startsWith('/')
15
+ const j = isPosix ? posixJoin : join
13
16
  if (path === '~') return home
14
- if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
17
+ if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
15
18
  return path
16
19
  }
17
20
 
@@ -22,12 +25,15 @@ export function expandHome(path: string, home: string = homedir()): string {
22
25
  * @returns the absolute DSH home path.
23
26
  */
24
27
  export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
28
+ const isPosix = home.startsWith('/')
29
+ const j = isPosix ? posixJoin : join
30
+ const isAbs = isPosix ? posixIsAbsolute : isAbsolute
25
31
  const raw = env.DSH_HOME
26
32
  if (raw !== undefined && raw.trim() !== '') {
27
33
  const expanded = expandHome(raw.trim(), home)
28
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
34
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
29
35
  }
30
- return join(home, '.dsh')
36
+ return j(home, '.dsh')
31
37
  }
32
38
 
33
39
  /** Resolve the DSH home directory from the live environment. */
@@ -10,7 +10,7 @@ import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archiv
10
10
  import { applyCreateTask } from './core/use-cases/task-create.ts'
11
11
  import { applyDeleteTask } from './core/use-cases/task-delete.ts'
12
12
  import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
13
- import { applyUpdateTask } from './core/use-cases/task-update.ts'
13
+ import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
14
14
  import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
15
15
 
16
16
  interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
@@ -482,6 +482,14 @@ export class HostTaskLedger {
482
482
  const task = this.document.tasks.find(task => task.id === action.taskId)
483
483
  if (task === undefined) throw new Error('task not found')
484
484
  if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
485
+ // The task content (title/description/prompt) is the record of what
486
+ // was planned; once an execution started it must not change under a
487
+ // running session or an executed history. Execution targets stay
488
+ // editable (they only affect future runs).
489
+ if (hasContentPatch(action.patch) && !canEditTaskContent(task)) {
490
+ throw new Error('task has already been executed')
491
+ }
492
+ if ('title' in action.patch && action.patch.title?.trim() === '') throw new Error('title is required')
485
493
  this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)]
486
494
  break
487
495
  }
package/src/http.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * edit this shared source and re-run the sync instead of editing a copy.
12
12
  * Consumer code is migrated onto it in follow-up waves; no call site changes
13
13
  * belong in the same change as its introduction.
14
- * @module dsh-web-ui-shared/host/http
14
+ * @module dsh-web-shared/host/http
15
15
  */
16
16
 
17
17
  import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
27
27
  export const inject = ['systemPrompt', 'apiProxy', 'webServer', 'agents', 'commands']
28
28
 
29
29
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
30
- export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
30
+ export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
31
31
 
32
32
  /**
33
33
  * Settings namespace of the board's announcement capability — the section the
package/src/loopback.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
9
  * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
10
  * markers (sec-fetch-site and Origin) for the request-level fence.
11
- * @module dsh-web-ui-shared/host/loopback
11
+ * @module dsh-web-shared/host/loopback
12
12
  */
13
13
 
14
14
  import type { IncomingMessage } from 'node:http'
package/src/mount-once.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
2
  /**
3
3
  * Host single-instance guard shared by the plugin family. The family bundle
4
- * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
4
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
5
  * the loader accepts a standalone install of the same package side by side;
6
6
  * without this guard the second instance would still re-register the same
7
7
  * webserver routes, tools, settings namespaces, and system-prompt sections
@@ -15,7 +15,7 @@
15
15
  * return value as the fiber disposer, so the unmarker is returned, not run.
16
16
  */
17
17
 
18
- const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
18
+ const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
19
 
20
20
  interface MountRegistry {
21
21
  [MOUNTED]?: Set<string>