@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.24

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 (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +2 -0
  3. package/README.zh.md +2 -0
  4. package/lib/client.js +726 -71
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2619 -175
  7. package/lib/types/client/board/EditTaskModal.d.ts +6 -0
  8. package/lib/types/client/board/NewTaskModal.d.ts +6 -1
  9. package/lib/types/client/board/TaskBoard.d.ts +2 -0
  10. package/lib/types/client/board/TaskForm.d.ts +8 -3
  11. package/lib/types/client/board/parse-model-pref.d.ts +4 -0
  12. package/lib/types/client/host-api.d.ts +23 -1
  13. package/lib/types/client/locales.d.ts +26 -0
  14. package/lib/types/client/plugin-card-seat.d.ts +96 -0
  15. package/lib/types/client/sidebar-entry-core.d.ts +32 -0
  16. package/lib/types/core/controller.d.ts +35 -1
  17. package/lib/types/core/session-reuse.d.ts +4 -2
  18. package/lib/types/host-ai.d.ts +51 -0
  19. package/lib/types/host-ledger.d.ts +15 -0
  20. package/lib/types/host-routes.d.ts +11 -1
  21. package/lib/types/index.d.ts +9 -0
  22. package/lib/types/protocol.d.ts +19 -0
  23. package/package.json +2 -1
  24. package/src/client/board/EditTaskModal.tsx +36 -0
  25. package/src/client/board/NewTaskModal.tsx +119 -5
  26. package/src/client/board/TaskBoard.tsx +89 -0
  27. package/src/client/board/TaskDetail.tsx +38 -2
  28. package/src/client/board/TaskForm.tsx +17 -11
  29. package/src/client/board/parse-model-pref.ts +35 -0
  30. package/src/client/board.module.css +61 -0
  31. package/src/client/host-api.ts +124 -5
  32. package/src/client/index.ts +18 -17
  33. package/src/client/locales.ts +52 -0
  34. package/src/client/plugin-card-seat.ts +185 -0
  35. package/src/client/sidebar-entry-core.ts +96 -12
  36. package/src/core/controller.ts +57 -1
  37. package/src/core/session-reuse.ts +14 -5
  38. package/src/host-ai.ts +152 -0
  39. package/src/host-ledger.ts +84 -14
  40. package/src/host-routes.ts +68 -3
  41. package/src/index.ts +26 -1
  42. package/src/protocol.ts +34 -0
@@ -18,7 +18,13 @@ import { applyCreateTask } from './use-cases/task-create.ts'
18
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
19
19
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
20
20
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
21
- import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts'
21
+ import type {
22
+ TaskBoardAction,
23
+ TaskBoardEventPayload,
24
+ TaskBoardParseDraft,
25
+ TaskBoardParseRequest,
26
+ TaskBoardSnapshot,
27
+ } from '../protocol.ts'
22
28
 
23
29
  export interface TaskBoardTransport {
24
30
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
@@ -29,6 +35,11 @@ export interface TaskBoardTransport {
29
35
  */
30
36
  action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
31
37
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
38
+ /**
39
+ * One-shot model parse of pasted text (issue #1540). Optional: a deployment
40
+ * that cannot parse simply omits it, and the form hides the section.
41
+ */
42
+ parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
32
43
  }
33
44
 
34
45
  /** The sessions face the controller needs for navigation awareness. */
@@ -57,6 +68,13 @@ export interface ControllerDeps {
57
68
  transport?: TaskBoardTransport
58
69
  }
59
70
 
71
+ /**
72
+ * Register one host directory as a DSH project (workspace). Wired by the
73
+ * browser apply() to the runtime's workspace controller; without it the board
74
+ * hides its "new project" action instead of offering a dead control (#1536).
75
+ */
76
+ export type WorkspaceCreator = (path: string) => Promise<{ workspaceId: string }>
77
+
60
78
  /** One workspace option the execution-target pickers offer. */
61
79
  export interface ExecutionWorkspaceOption {
62
80
  workspaceId: string
@@ -99,6 +117,10 @@ export interface ControllerSnapshot {
99
117
  /** Picker option sets (workspace list + agent-preset roster). */
100
118
  executionOptions: ExecutionOptionsSnapshot
101
119
  pendingTaskIds: readonly string[]
120
+ /** Whether the board may offer "register a new project" (issue #1536). */
121
+ canCreateWorkspace?: boolean
122
+ /** Whether this deployment can parse pasted text into task fields (issue #1540). */
123
+ canParseTask?: boolean
102
124
  transportError?: string
103
125
  host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
104
126
  }
@@ -139,6 +161,7 @@ export class BoardController {
139
161
  private archiveView = false
140
162
  private selectedTaskId: string | undefined
141
163
  private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [], models: [] }
164
+ private workspaceCreator: WorkspaceCreator | undefined
142
165
  private listeners = new Set<() => void>()
143
166
  private disposers: Array<() => void> = []
144
167
  private readonly now: () => number
@@ -193,6 +216,8 @@ export class BoardController {
193
216
  selectedTaskId: this.selectedTaskId,
194
217
  executionOptions: this.executionOptions,
195
218
  pendingTaskIds: [...this.pendingTaskIds],
219
+ ...(this.workspaceCreator === undefined ? {} : { canCreateWorkspace: true }),
220
+ ...(typeof this.deps.transport?.parseDraft === 'function' ? { canParseTask: true } : {}),
196
221
  ...(this.transportError === undefined ? {} : { transportError: this.transportError }),
197
222
  ...(this.hostState === undefined ? {} : { host: this.hostState }),
198
223
  }
@@ -306,6 +331,37 @@ export class BoardController {
306
331
  this.notify()
307
332
  }
308
333
 
334
+ /** Wire (or clear) the runtime's project registration face (issue #1536). */
335
+ setWorkspaceCreator(creator: WorkspaceCreator | undefined): void {
336
+ this.workspaceCreator = creator
337
+ this.notify()
338
+ }
339
+
340
+ /**
341
+ * Register an existing host directory as a DSH project, exactly as the GUI's
342
+ * own "add project" does; the runtime's failure message surfaces unchanged.
343
+ */
344
+ async createWorkspace(path: string): Promise<{ workspaceId: string }> {
345
+ if (this.workspaceCreator === undefined) throw new Error('workspace creation is unavailable')
346
+ return await this.workspaceCreator(path)
347
+ }
348
+
349
+ /** Whether this deployment can parse pasted text into task fields (issue #1540). */
350
+ canParseTask(): boolean {
351
+ return typeof this.deps.transport?.parseDraft === 'function'
352
+ }
353
+
354
+ /**
355
+ * Parse pasted text into task fields through the Host. The transport already
356
+ * phrases every failure for the user, so its message surfaces unchanged.
357
+ */
358
+ async parseTaskDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
359
+ const transport = this.deps.transport
360
+ const parse = transport?.parseDraft
361
+ if (transport === undefined || parse === undefined) throw new Error('task parsing is unavailable')
362
+ return await parse.call(transport, request, signal)
363
+ }
364
+
309
365
  moveTask(id: string, status: TaskStatus): void {
310
366
  if (this.deps.transport !== undefined) {
311
367
  void this.commitRemote({ kind: 'move', taskId: id, status }, id)
@@ -3,14 +3,16 @@
3
3
  * previous execution's conversation. Pure, so the fail-closed conditions stay
4
4
  * unit-testable without a gateway.
5
5
  */
6
- import type { TaskRecord } from './tasks.ts'
6
+ import type { ExecutionRecord, TaskRecord } from './tasks.ts'
7
7
 
8
8
  /**
9
9
  * Pick the session a new execution of this task may continue in, or undefined
10
10
  * to mint a fresh conversation. Reuse requires ALL of:
11
11
  * - the task opted in (`reuseSession === true`);
12
- * - the task's newest execution carries a session id and has settled (an open
13
- * execution would have made the ledger refuse the run in the first place);
12
+ * - the task's newest SETTLED execution carries a session id (issue #1587: the
13
+ * launch path calls this after `startExecution` appended this run's own open
14
+ * record, so reading the array tail always found an unsettled row and reuse
15
+ * never happened);
14
16
  * - the roster is known and that session is present and idle.
15
17
  *
16
18
  * An unknown roster (session/list unavailable) never reuses: minting a fresh
@@ -25,7 +27,14 @@ export function reusableSessionId(
25
27
  idleSessionIds: ReadonlySet<string> | undefined,
26
28
  ): string | undefined {
27
29
  if (task.reuseSession !== true || idleSessionIds === undefined) return undefined
28
- const last = task.executions[task.executions.length - 1]
29
- if (last === undefined || last.sessionId === undefined || last.endedAt === undefined) return undefined
30
+ let last: ExecutionRecord | undefined
31
+ for (let index = task.executions.length - 1; index >= 0; index -= 1) {
32
+ const candidate = task.executions[index]
33
+ if (candidate.sessionId !== undefined && candidate.endedAt !== undefined) {
34
+ last = candidate
35
+ break
36
+ }
37
+ }
38
+ if (last === undefined || last.sessionId === undefined) return undefined
30
39
  return idleSessionIds.has(last.sessionId) ? last.sessionId : undefined
31
40
  }
package/src/host-ai.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * One-shot model parse behind the board's "parse pasted text" action
3
+ * (issue #1540).
4
+ *
5
+ * The browser half never talks to a model: it posts the pasted text and the
6
+ * route the user picked to the Host, which calls the injected `llm` service
7
+ * once and answers with the three draft fields the new-task form accepts. The
8
+ * reply is untrusted text, so extraction is defensive: code fences are
9
+ * stripped, the first JSON object wins, and an unusable reply falls back to
10
+ * the user's own words instead of an empty form.
11
+ */
12
+ import { createUserMessage, type GenerateOptions, type LlmRuntime } from '@deepseek-ai/dsh-llm'
13
+ import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts'
14
+
15
+ /** How long one parse may take before the Host gives up on the model. */
16
+ export const TASK_PARSE_TIMEOUT_MS = 45_000
17
+ /** Largest pasted text one parse accepts (bytes). */
18
+ export const TASK_PARSE_MAX_INPUT = 16 * 1024
19
+ /** Longest title the form is willing to receive from a model. */
20
+ const TASK_PARSE_TITLE_LIMIT = 120
21
+
22
+ export type TaskParseFailureCode = 'no-model' | 'model-error' | 'parse-failed' | 'timeout'
23
+
24
+ /** Typed parse failure; the route maps the code onto a status and a body. */
25
+ export class TaskParseError extends Error {
26
+ constructor(readonly code: TaskParseFailureCode, message: string) {
27
+ super(message)
28
+ this.name = 'TaskParseError'
29
+ }
30
+ }
31
+
32
+ const SYSTEM_PROMPT = [
33
+ 'You turn one pasted note into a task for a kanban board.',
34
+ 'Reply with a single JSON object and nothing else, with exactly these keys:',
35
+ '{"title": string, "description": string, "prompt": string}',
36
+ '- title: one line, at most 80 characters, in the language of the note.',
37
+ '- description: what the task is and the context the note carries, as plain text.',
38
+ '- prompt: the instruction an agent should execute, keeping every fact that matters.',
39
+ ].join('\n')
40
+
41
+ /** Split a qualified `provider/model` route the same way the runner does. */
42
+ export function splitModelRoute(qualified: string | undefined): { provider: string; model: string } | undefined {
43
+ const raw = qualified?.trim() ?? ''
44
+ if (raw === '') return undefined
45
+ const slash = raw.indexOf('/')
46
+ if (slash <= 0 || slash === raw.length - 1) return undefined
47
+ const provider = raw.slice(0, slash).trim()
48
+ const model = raw.slice(slash + 1).trim()
49
+ if (provider === '' || model === '') return undefined
50
+ return { provider, model }
51
+ }
52
+
53
+ /** Trim one model-provided field to a single-line, bounded string. */
54
+ function field(value: unknown, limit?: number): string {
55
+ if (typeof value !== 'string') return ''
56
+ const text = value.trim()
57
+ return limit === undefined || text.length <= limit ? text : text.slice(0, limit).trim()
58
+ }
59
+
60
+ /** First non-empty line of the pasted text, used when the model omits a title. */
61
+ function firstLine(source: string): string {
62
+ const line = source.split(/\r?\n/).map(entry => entry.trim()).find(entry => entry !== '') ?? ''
63
+ return line.length <= TASK_PARSE_TITLE_LIMIT ? line : line.slice(0, TASK_PARSE_TITLE_LIMIT).trim()
64
+ }
65
+
66
+ /**
67
+ * Read the draft out of a model reply. Fences and surrounding prose are
68
+ * tolerated; anything unusable returns undefined so the caller can decide
69
+ * between a typed failure and a fallback.
70
+ * @param reply - raw model text.
71
+ */
72
+ export function extractTaskParseReply(reply: string): Partial<TaskBoardParseDraft> | undefined {
73
+ const withoutFences = reply.replace(/```[a-zA-Z]*\s*/g, '')
74
+ const start = withoutFences.indexOf('{')
75
+ const end = withoutFences.lastIndexOf('}')
76
+ if (start === -1 || end <= start) return undefined
77
+ let payload: unknown
78
+ try {
79
+ payload = JSON.parse(withoutFences.slice(start, end + 1))
80
+ } catch {
81
+ return undefined
82
+ }
83
+ if (typeof payload !== 'object' || payload === null) return undefined
84
+ const record = payload as { title?: unknown; description?: unknown; prompt?: string }
85
+ const draft: Partial<TaskBoardParseDraft> = {
86
+ title: field(record.title, TASK_PARSE_TITLE_LIMIT),
87
+ description: field(record.description),
88
+ prompt: field(record.prompt),
89
+ }
90
+ return draft.title === '' && draft.description === '' && draft.prompt === '' ? undefined : draft
91
+ }
92
+
93
+ /**
94
+ * Fill the three form fields, falling back to the pasted text so a weak model
95
+ * reply never loses what the user actually wrote.
96
+ * @param reply - raw model text.
97
+ * @param source - the text the user pasted.
98
+ */
99
+ export function draftFromReply(reply: string, source: string): TaskBoardParseDraft {
100
+ const parsed = extractTaskParseReply(reply)
101
+ return {
102
+ title: parsed?.title !== undefined && parsed.title !== '' ? parsed.title : firstLine(source),
103
+ description: parsed?.description ?? '',
104
+ prompt: parsed?.prompt !== undefined && parsed.prompt !== '' ? parsed.prompt : source.trim(),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Parse one pasted text through the injected `llm` service.
110
+ * @param llm - the Host's llm service.
111
+ * @param request - pasted text plus the qualified model route.
112
+ * @param signal - caller cancellation (client disconnect); combined with the timeout.
113
+ * @returns the draft fields.
114
+ * @throws TaskParseError with a code the route maps onto a status.
115
+ */
116
+ export async function parseTaskDraft(
117
+ llm: LlmRuntime,
118
+ request: TaskBoardParseRequest,
119
+ signal?: AbortSignal,
120
+ ): Promise<TaskBoardParseDraft> {
121
+ const text = request.text.trim()
122
+ if (text === '') throw new TaskParseError('parse-failed', 'there is nothing to parse')
123
+ const route = splitModelRoute(request.model)
124
+ if (route === undefined) throw new TaskParseError('no-model', 'no model route was selected for parsing')
125
+ const timeout = new AbortController()
126
+ const timer = setTimeout(() => { timeout.abort() }, TASK_PARSE_TIMEOUT_MS)
127
+ const abortFromCaller = (): void => { timeout.abort() }
128
+ signal?.addEventListener('abort', abortFromCaller, { once: true })
129
+ try {
130
+ const options: GenerateOptions = {
131
+ provider: route.provider,
132
+ model: route.model,
133
+ system: SYSTEM_PROMPT,
134
+ messages: [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })],
135
+ signal: timeout.signal,
136
+ }
137
+ let reply = ''
138
+ for await (const chunk of llm.stream(options)) {
139
+ if (chunk.type === 'text-delta') reply += chunk.text
140
+ }
141
+ return draftFromReply(reply, text)
142
+ } catch (error) {
143
+ if (error instanceof TaskParseError) throw error
144
+ if (timeout.signal.aborted) {
145
+ throw new TaskParseError('timeout', `the model did not answer within ${TASK_PARSE_TIMEOUT_MS / 1_000}s`)
146
+ }
147
+ throw new TaskParseError('model-error', error instanceof Error ? error.message : String(error))
148
+ } finally {
149
+ clearTimeout(timer)
150
+ signal?.removeEventListener('abort', abortFromCaller)
151
+ }
152
+ }
@@ -134,6 +134,11 @@ export function processIsAlive(pid: number): boolean {
134
134
  }
135
135
 
136
136
  const PROCESS_PROBE_TIMEOUT_MS = 3000
137
+ /**
138
+ * The CIM fallback pays a WMI cold start, so it gets a wider budget than the
139
+ * direct `Get-Process` read it backs up.
140
+ */
141
+ const CIM_PROBE_TIMEOUT_MS = 8000
137
142
 
138
143
  let ownStartTime: number | undefined
139
144
  let ownStartTimeResolved = false
@@ -161,6 +166,59 @@ function linuxStartTimeMs(pid: number): number | undefined {
161
166
  }
162
167
  }
163
168
 
169
+ /** Runs one PowerShell script and returns its trimmed stdout. */
170
+ export type PowerShellProbe = (script: string, timeoutMs: number) => string | undefined
171
+
172
+ /** Default probe: one hidden, profile-free PowerShell process per script. */
173
+ const runPowerShellProbe: PowerShellProbe = (script, timeoutMs) => {
174
+ const probe = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
175
+ timeout: timeoutMs,
176
+ windowsHide: true,
177
+ })
178
+ if (probe.status !== 0 || probe.stdout.length === 0) return undefined
179
+ return probe.stdout.toString('utf8').trim()
180
+ }
181
+
182
+ /** The epoch-millisecond reading a probe printed, or undefined when unusable. */
183
+ function parseProbeEpochMs(raw: string | undefined): number | undefined {
184
+ if (raw === undefined || raw.trim() === '') return undefined
185
+ const started = Number(raw.trim())
186
+ return Number.isFinite(started) ? started : undefined
187
+ }
188
+
189
+ /** Start time through Get-Process: precise, but empty for protected processes. */
190
+ function getProcessStartScript(pid: number): string {
191
+ return '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid)
192
+ + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'
193
+ }
194
+
195
+ /** Start time through Win32_Process: readable for System/svchost too. */
196
+ function cimStartScript(pid: number): string {
197
+ return '$p=Get-CimInstance Win32_Process -Filter "ProcessId=' + String(pid)
198
+ + '" -ErrorAction SilentlyContinue;if($p -ne $null){[DateTimeOffset]::FromFileTime($p.CreationDate.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()}'
199
+ }
200
+
201
+ /**
202
+ * Windows start time (Unix epoch ms) of a live process.
203
+ *
204
+ * `Get-Process` is the precise first choice, but an unprivileged caller cannot
205
+ * read `.StartTime` for a protected process (System, svchost): the property is
206
+ * empty, so the probe returns nothing. Without a fallback, a crash leftover
207
+ * lock whose PID was reused by such a process could never be proven stale and
208
+ * blocked every startup until the lock was deleted by hand (issue #1629).
209
+ * Win32_Process through CIM reports the same CreationDate for those processes
210
+ * at the same millisecond precision, so it is the second identity source. The
211
+ * probe is injectable so the fallback chain is testable off Windows.
212
+ */
213
+ export function win32StartTimeMs(pid: number, probe: PowerShellProbe = runPowerShellProbe): number | undefined {
214
+ // The pid is interpolated into a PowerShell script, so it must be a plain
215
+ // positive integer before any probe runs.
216
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined
217
+ const direct = parseProbeEpochMs(probe(getProcessStartScript(pid), PROCESS_PROBE_TIMEOUT_MS))
218
+ if (direct !== undefined) return direct
219
+ return parseProbeEpochMs(probe(cimStartScript(pid), CIM_PROBE_TIMEOUT_MS))
220
+ }
221
+
164
222
  /**
165
223
  * Best-effort start time (Unix epoch ms) of a live process. Used to prove
166
224
  * whether the ledger lock really belongs to the PID recorded in it, so a
@@ -170,17 +228,7 @@ function linuxStartTimeMs(pid: number): number | undefined {
170
228
  */
171
229
  function processStartTimeMs(pid: number): number | undefined {
172
230
  if (process.platform === 'linux') return linuxStartTimeMs(pid)
173
- if (process.platform === 'win32') {
174
- const probe = spawnSync(
175
- 'powershell',
176
- ['-NoProfile', '-NonInteractive', '-Command',
177
- '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
178
- { timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
179
- )
180
- if (probe.status !== 0 || probe.stdout.length === 0) return undefined
181
- const started = Number(probe.stdout.toString('utf8').trim())
182
- return Number.isFinite(started) ? started : undefined
183
- }
231
+ if (process.platform === 'win32') return win32StartTimeMs(pid)
184
232
  // Other POSIX (macOS...): ps lstart with a forced English locale, falling
185
233
  // back to the elapsed-seconds column when lstart cannot be parsed.
186
234
  const env = { ...process.env, LC_ALL: 'C' }
@@ -216,6 +264,16 @@ function ownProcessStartTimeMs(): number | undefined {
216
264
  */
217
265
  const LEGACY_START_TOLERANCE_MS = 2000
218
266
 
267
+ /**
268
+ * How long an unreadable lock must sit untouched before it may be reclaimed.
269
+ * The owner writes and fsyncs its record immediately after creating the file
270
+ * with O_EXCL, so a lock that cannot be parsed may still be mid-write by a
271
+ * live owner; only one that has been unreadable for longer than any write can
272
+ * take is treated as an unclean-shutdown leftover (issue #1528: a 0-byte lock
273
+ * kept the Host half from mounting until it was deleted by hand).
274
+ */
275
+ const UNREADABLE_LOCK_GRACE_MS = 60_000
276
+
219
277
  /** Whether the recorded start time proves the recorded PID is another process. */
220
278
  function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
221
279
  return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
@@ -842,9 +900,21 @@ export class HostTaskLedger {
842
900
  if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
843
901
  ownerExact = owner.probe === 'exact'
844
902
  } catch {
845
- // A power-loss mid-write can leave a truncated lock; the same event
846
- // killed the writer, so fail closed but explain the recovery.
847
- throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
903
+ // A power-loss mid-write can leave an empty or truncated lock. Such a
904
+ // lock still fails closed while it is fresh (a live owner may be
905
+ // mid-write); once it is older than the grace window nothing can be
906
+ // writing it, so the leftover is reclaimed instead of blocking every
907
+ // later start until someone deletes it by hand (issue #1528).
908
+ const age = (() => {
909
+ try { return this.now() - statSync(this.lockFile).mtimeMs } catch { return Number.POSITIVE_INFINITY }
910
+ })()
911
+ if (age < UNREADABLE_LOCK_GRACE_MS) {
912
+ throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
913
+ }
914
+ try { unlinkSync(this.lockFile) } catch (unlinkError) {
915
+ if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
916
+ }
917
+ continue
848
918
  }
849
919
  if (pid !== undefined && processIsAlive(pid)) {
850
920
  const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
@@ -4,7 +4,14 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
4
4
  import type { TaskBoardHostService } from './host-service.ts'
5
5
  import { writeJson } from './http.ts'
6
6
  import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
7
- import { parseActionEnvelope, TASK_BOARD_API_PREFIX } from './protocol.ts'
7
+ import { TaskParseError, TASK_PARSE_MAX_INPUT } from './host-ai.ts'
8
+ import {
9
+ parseActionEnvelope,
10
+ parseTaskParseRequest,
11
+ TASK_BOARD_API_PREFIX,
12
+ type TaskBoardParseDraft,
13
+ type TaskBoardParseRequest,
14
+ } from './protocol.ts'
8
15
 
9
16
  const ACTION_LIMIT = 64 * 1024
10
17
  const IMPORT_LIMIT = 2 * 1024 * 1024
@@ -19,6 +26,16 @@ export interface TaskBoardRouteAccess {
19
26
  proxyToken?: string
20
27
  }
21
28
 
29
+ /**
30
+ * Host faces the routes need beyond the ledger service. The parse face is
31
+ * resolved lazily by the caller, so a deployment without an llm service still
32
+ * registers the route and answers with a typed "no model" failure instead of
33
+ * leaving the panel with an unmounted path (issue #1540).
34
+ */
35
+ export interface TaskBoardRouteOptions {
36
+ parseTask?: (request: TaskBoardParseRequest, signal: AbortSignal) => Promise<TaskBoardParseDraft>
37
+ }
38
+
22
39
  interface ResolvedRouteAccess {
23
40
  trustedProxyHosts: ReadonlySet<string>
24
41
  proxyToken?: string
@@ -119,7 +136,11 @@ async function readBody(req: IncomingMessage): Promise<{ raw: string; value: unk
119
136
  return { raw, value: JSON.parse(raw) }
120
137
  }
121
138
 
122
- export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskBoardRouteAccess = {}): WebRoute[] {
139
+ export function makeTaskBoardRoutes(
140
+ service: TaskBoardHostService,
141
+ access: TaskBoardRouteAccess = {},
142
+ options: TaskBoardRouteOptions = {},
143
+ ): WebRoute[] {
123
144
  const resolvedAccess = resolveAccess(access)
124
145
  const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
125
146
  if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
@@ -188,5 +209,49 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
188
209
  push()
189
210
  },
190
211
  }
191
- return [state, action, events]
212
+ const parse: WebRoute = {
213
+ kind: 'exact',
214
+ path: `${TASK_BOARD_API_PREFIX}/parse`,
215
+ handler: async (req, res): Promise<void> => {
216
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
217
+ if (!guard(req, res)) return
218
+ if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
219
+ return writeJson(res, 415, { ok: false, error: 'json-required' }, { 'cache-control': 'no-store' })
220
+ }
221
+ let body: { raw: string; value: unknown }
222
+ try {
223
+ body = await readBody(req)
224
+ } catch (error) {
225
+ const message = error instanceof Error ? error.message : String(error)
226
+ return writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
227
+ }
228
+ const request = parseTaskParseRequest(body.value)
229
+ if (request === undefined) return writeJson(res, 400, { ok: false, error: 'invalid-parse-request' }, { 'cache-control': 'no-store' })
230
+ if (Buffer.byteLength(request.text, 'utf8') > TASK_PARSE_MAX_INPUT) {
231
+ return writeJson(res, 413, { ok: false, error: 'text-too-large' }, { 'cache-control': 'no-store' })
232
+ }
233
+ const parseTask = options.parseTask
234
+ if (parseTask === undefined) {
235
+ return writeJson(res, 503, { ok: false, code: 'no-model', error: 'task-board parsing is unavailable' }, { 'cache-control': 'no-store' })
236
+ }
237
+ // The model call outlives a closed tab: stop it when the response goes
238
+ // away instead of holding the provider request open for the full timeout.
239
+ const controller = new AbortController()
240
+ const onClose = (): void => { controller.abort() }
241
+ res.once('close', onClose)
242
+ try {
243
+ const draft = await parseTask(request, controller.signal)
244
+ writeJson(res, 200, { ok: true, draft }, { 'cache-control': 'no-store' })
245
+ } catch (error) {
246
+ const failure = error instanceof TaskParseError
247
+ ? error
248
+ : new TaskParseError('model-error', error instanceof Error ? error.message : String(error))
249
+ const status = failure.code === 'no-model' ? 503 : failure.code === 'timeout' ? 504 : 502
250
+ writeJson(res, status, { ok: false, code: failure.code, error: failure.message }, { 'cache-control': 'no-store' })
251
+ } finally {
252
+ res.off('close', onClose)
253
+ }
254
+ },
255
+ }
256
+ return [state, action, events, parse]
192
257
  }
package/src/index.ts CHANGED
@@ -15,7 +15,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
15
15
  import type {} from '@deepseek-ai/dsh-api-gateway'
16
16
  import type {} from '@deepseek-ai/dsh-workspace'
17
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
+ import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
18
19
  import { TaskBoardHostService } from './host-service.ts'
20
+ import { parseTaskDraft, TaskParseError } from './host-ai.ts'
19
21
  import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
20
22
  import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
21
23
  import { makeTaskBoardRoutes } from './host-routes.ts'
@@ -88,6 +90,22 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
88
90
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
89
91
  const DEFAULT_ANNOUNCE = false
90
92
 
93
+ /**
94
+ * Read the optional `llm` service. The board deliberately does not inject it:
95
+ * a deployment without a model must still mount the board, and the parse route
96
+ * answers a typed failure instead of the plugin failing to load (issue #1540).
97
+ * @param ctx - the plugin context.
98
+ * @returns the llm service, or undefined when this deployment serves none.
99
+ */
100
+ export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
101
+ try {
102
+ const llm = ctx.get('llm') as LlmRuntime | undefined
103
+ return llm !== undefined && typeof (llm as { stream?: unknown }).stream === 'function' ? llm : undefined
104
+ } catch {
105
+ return undefined
106
+ }
107
+ }
108
+
91
109
  /**
92
110
  * Register the board's announcement section, gated on the composition entry's
93
111
  * `announceToAgent` (and the live settings value once the web settings
@@ -115,7 +133,14 @@ function applyImpl(ctx: Context, config?: Config): void {
115
133
  ctx.effect(() => {
116
134
  const disposers: Array<() => void> = []
117
135
  try {
118
- for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route))
136
+ const routes = makeTaskBoardRoutes(host, resolveProxyAccess(config), {
137
+ parseTask: async (request, signal) => {
138
+ const llm = resolveLlmRuntime(ctx)
139
+ if (llm === undefined) throw new TaskParseError('no-model', 'this deployment serves no llm service')
140
+ return await parseTaskDraft(llm, request, signal)
141
+ },
142
+ })
143
+ for (const route of routes) disposers.push(ctx.webServer.register(route))
119
144
  } catch (error) {
120
145
  for (const dispose of disposers) dispose()
121
146
  host.dispose()
package/src/protocol.ts CHANGED
@@ -49,6 +49,40 @@ export interface TaskBoardEventPayload {
49
49
  power: TaskBoardPowerSnapshot
50
50
  }
51
51
 
52
+ /**
53
+ * Request body of `POST {TASK_BOARD_API_PREFIX}/parse` (issue #1540): the text
54
+ * the user pasted plus the `provider/model` route that should parse it.
55
+ */
56
+ export interface TaskBoardParseRequest {
57
+ text: string
58
+ /** Qualified route from the model picker; absent when no route was chosen. */
59
+ model?: string
60
+ }
61
+
62
+ /** Draft fields a parse returns; the user reviews them before creating the task. */
63
+ export interface TaskBoardParseDraft {
64
+ title: string
65
+ description: string
66
+ prompt: string
67
+ }
68
+
69
+ /** Strict parse of a `/parse` request body; undefined rejects the request. */
70
+ export function parseTaskParseRequest(value: unknown): TaskBoardParseRequest | undefined {
71
+ if (typeof value !== 'object' || value === null) return undefined
72
+ const record = value as { text?: unknown; model?: unknown }
73
+ if (typeof record.text !== 'string' || record.text.trim() === '') return undefined
74
+ if (record.model !== undefined && typeof record.model !== 'string') return undefined
75
+ const model = typeof record.model === 'string' ? record.model.trim() : ''
76
+ return { text: record.text, ...(model === '' ? {} : { model }) }
77
+ }
78
+
79
+ /** Whether a decoded reply carries the three draft strings the form accepts. */
80
+ export function isTaskParseDraft(value: unknown): value is TaskBoardParseDraft {
81
+ if (typeof value !== 'object' || value === null) return false
82
+ const record = value as { title?: unknown; description?: unknown; prompt?: unknown }
83
+ return typeof record.title === 'string' && typeof record.description === 'string' && typeof record.prompt === 'string'
84
+ }
85
+
52
86
  export type TaskBoardAction =
53
87
  | { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
54
88
  | { kind: 'create'; id: string; input: NewTaskInput }