dsh-taskboard 0.6.7 → 0.7.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.
@@ -8,7 +8,7 @@ import type { SessionArchiveResult } from '../shared/api.ts'
8
8
  *
9
9
  * @module dsh-taskboard/client/controller
10
10
  */
11
- import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
+ import type { AttachmentUpload, ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, StorageStatus, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
12
12
  import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
13
13
  import { emptyLedger } from '../shared/protocol.ts'
14
14
  import type { TaskboardClient } from './api.ts'
@@ -78,6 +78,8 @@ export interface ControllerState {
78
78
  importOpen: boolean
79
79
  /** Board-settings modal visible (0.5.0). */
80
80
  settingsOpen: boolean
81
+ /** Current durable-data directory, loaded when settings opens. */
82
+ storage?: StorageStatus
81
83
  /** Fields a chosen template prefills into the create form (consumed on open). */
82
84
  templatePrefill?: TaskTemplateSpec
83
85
  /** Transient error surface (action failures); cleared on next success. */
@@ -524,6 +526,16 @@ export class BoardController {
524
526
  }
525
527
  }
526
528
 
529
+ /** Upload an image without putting its bytes in the ledger or agent context. */
530
+ async uploadImage(file: Blob): Promise<AttachmentUpload | undefined> {
531
+ try {
532
+ return await this.client.uploadImage(file)
533
+ } catch (error) {
534
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
535
+ return undefined
536
+ }
537
+ }
538
+
527
539
  /** Trigger a manual run (fresh in-project session, pinned model); `reuse` = 续跑. */
528
540
  async run(id: string, reuse = false): Promise<void> {
529
541
  try {
@@ -587,7 +599,12 @@ export class BoardController {
587
599
  closeDiagnostics(): void { this.setState({ diagOpen: false }) }
588
600
 
589
601
  /** Open the board-settings modal (0.5.0). */
590
- openSettings(): void { this.setState({ settingsOpen: true }) }
602
+ openSettings(): void {
603
+ this.setState({ settingsOpen: true })
604
+ void this.client.storage()
605
+ .then(storage => this.setState({ storage, error: undefined }))
606
+ .catch(error => this.setState({ error: error instanceof Error ? error.message : String(error) }))
607
+ }
591
608
 
592
609
  /** Close the board-settings modal. */
593
610
  closeSettings(): void { this.setState({ settingsOpen: false }) }
@@ -608,6 +625,31 @@ export class BoardController {
608
625
  }
609
626
  }
610
627
 
628
+ /** Validate a candidate host directory without changing the active store. */
629
+ async checkStorage(directory: string): Promise<boolean> {
630
+ try {
631
+ const storage = await this.client.checkStorage(directory)
632
+ this.setState({ storage, error: undefined })
633
+ return true
634
+ } catch (error) {
635
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
636
+ return false
637
+ }
638
+ }
639
+
640
+ /** Atomically migrate all three stores and refresh the displayed location. */
641
+ async migrateStorage(directory: string): Promise<boolean> {
642
+ try {
643
+ const storage = await this.client.migrateStorage(directory)
644
+ this.setState({ storage, error: storage.warnings.length === 0 ? undefined : storage.warnings.join('\n') })
645
+ await this.refresh()
646
+ return true
647
+ } catch (error) {
648
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
649
+ return false
650
+ }
651
+ }
652
+
611
653
  /** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
612
654
  async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
613
655
  try {
@@ -339,14 +339,14 @@ export const en: TaskboardDict = {
339
339
  'tpl.use.button': 'Use',
340
340
  'tpl.delete.title': 'Delete this template',
341
341
  'tpl.renamed': 'Template renamed',
342
- 'tpl.foot.hint': 'Templates are stored with the ledger in the DSH home directory and survive upgrades',
342
+ 'tpl.foot.hint': 'Templates are stored with the ledger in the active data directory and survive upgrades',
343
343
 
344
344
  // ── import modal (ImportModal) ────────────────────────────────────
345
345
  'imp.aria': 'Import ledger',
346
346
  'imp.title': 'Import ledger',
347
347
  'imp.subtitle': 'Pick an exported JSON backup: preview first, then merge or replace everything',
348
348
  'imp.parseError': 'The file is not valid JSON',
349
- 'imp.note': 'The ⬇ JSON export is a same-format backup and can be imported to restore; the file\u2019s schemaVersion must match the current version.',
349
+ 'imp.note': 'The ⬇ JSON export restores the ledger. Back up the dsh-taskboard-assets folder in the data directory shown in Settings as well. The file\u2019s schemaVersion must match the current version.',
350
350
  'imp.previewing': 'Previewing…',
351
351
  'imp.stat.create': 'New',
352
352
  'imp.stat.overwrite': 'Overwrite (same id)',
@@ -370,7 +370,7 @@ export const en: TaskboardDict = {
370
370
  // ── board settings modal (SettingsModal) ──────────────────────────
371
371
  'set.aria': 'Board settings',
372
372
  'set.title': 'Board settings',
373
- 'set.subtitle': 'Global defaults for new tasks and session sync',
373
+ 'set.subtitle': 'New-task defaults, session sync, and local data storage',
374
374
  'set.iso.heading': 'Default execution isolation',
375
375
  'set.iso.noneHint': 'No git; works directly in the project directory (factory default)',
376
376
  'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others; multi-repo workspaces are mirrored whole (one branch per repo)',
@@ -387,6 +387,16 @@ export const en: TaskboardDict = {
387
387
  'set.foot.dirty': 'Unsaved changes',
388
388
  'set.foot.clean': 'Matches the current board settings',
389
389
  'set.action.save': 'Save settings',
390
+ 'set.storage.heading': 'Data storage location',
391
+ 'set.storage.hint': 'The task ledger, templates, and image attachments live together in this directory. Changing it copies and verifies everything before switching.',
392
+ 'set.storage.loading': 'Loading the current path…',
393
+ 'set.storage.current': 'Current path: {path}',
394
+ 'set.storage.assets': 'Image attachments: {count}, {size} MiB',
395
+ 'set.storage.default': 'Restore default path',
396
+ 'set.storage.check': 'Check path',
397
+ 'set.storage.migrate': 'Migrate data',
398
+ 'set.storage.migrating': 'Migrating…',
399
+ 'set.storage.confirm': 'Migrate the complete task ledger, templates, and image attachments?\n\nFrom: {from}\nTo: {to}\n\nWrites queue briefly during migration.',
390
400
 
391
401
  // ── execution permission (PR #14) ─────────────────────────────────
392
402
  'form.field.permission': 'Execution permission',
@@ -420,6 +430,10 @@ export const en: TaskboardDict = {
420
430
  'md.imageTitle': 'Click to view full size ({alt})',
421
431
  'md.lightboxAlt': 'Full-size preview',
422
432
  'md.closePreview': 'Close preview',
433
+ 'image.add': 'Insert image',
434
+ 'image.uploading': 'Uploading…',
435
+ 'image.hint': 'Choose, paste, or drop PNG/JPEG/GIF/WebP images up to 5 MB each',
436
+ 'image.defaultAlt': 'Image',
423
437
 
424
438
  // ── slash completion popup (PR #14) ───────────────────────────────
425
439
  'slash.aria': 'Quick commands and skills',
@@ -341,14 +341,14 @@ export const zh = {
341
341
  'tpl.use.button': '用此新建',
342
342
  'tpl.delete.title': '删除该模板',
343
343
  'tpl.renamed': '模板已改名',
344
- 'tpl.foot.hint': '模板随台账一同保存在 DSH 主目录,升级不丢',
344
+ 'tpl.foot.hint': '模板随台账一同保存在当前数据目录,升级不丢',
345
345
 
346
346
  // ── import modal (ImportModal) ────────────────────────────────────
347
347
  'imp.aria': '导入台账',
348
348
  'imp.title': '导入台账',
349
349
  'imp.subtitle': '选择导出的 JSON 备份文件:先预览、再合并或整册替换',
350
350
  'imp.parseError': '文件不是合法 JSON',
351
- 'imp.note': '⬇ JSON 导出的文件即为同格式备份,可直接导入恢复;导入文件的 schemaVersion 必须与当前版本一致。',
351
+ 'imp.note': '⬇ JSON 导出可恢复台账;图片附件需同时备份设置页所示数据目录下的 dsh-taskboard-assets 文件夹。导入文件的 schemaVersion 必须与当前版本一致。',
352
352
  'imp.previewing': '预览中…',
353
353
  'imp.stat.create': '新增',
354
354
  'imp.stat.overwrite': '覆盖(同 id)',
@@ -372,7 +372,7 @@ export const zh = {
372
372
  // ── board settings modal (SettingsModal) ──────────────────────────
373
373
  'set.aria': '看板设置',
374
374
  'set.title': '看板设置',
375
- 'set.subtitle': '新建任务与会话同步的全局默认值',
375
+ 'set.subtitle': '新建任务、会话同步与本地数据存储',
376
376
  'set.iso.heading': '默认执行隔离',
377
377
  'set.iso.noneHint': '不使用 git,直接在项目目录工作(出厂默认)',
378
378
  'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染;多仓库工作区自动整区镜像(每仓库独立分支)',
@@ -389,6 +389,16 @@ export const zh = {
389
389
  'set.foot.dirty': '有未保存的修改',
390
390
  'set.foot.clean': '与看板当前设置一致',
391
391
  'set.action.save': '保存设置',
392
+ 'set.storage.heading': '数据存储位置',
393
+ 'set.storage.hint': '任务台账、模板和图片附件统一存放在此目录。修改路径会先完整复制并校验,再切换到新位置。',
394
+ 'set.storage.loading': '正在读取当前路径…',
395
+ 'set.storage.current': '当前路径:{path}',
396
+ 'set.storage.assets': '图片附件:{count} 个,{size} MiB',
397
+ 'set.storage.default': '恢复默认路径',
398
+ 'set.storage.check': '检查路径',
399
+ 'set.storage.migrate': '迁移数据',
400
+ 'set.storage.migrating': '迁移中…',
401
+ 'set.storage.confirm': '确认迁移全部任务台账、模板和图片附件?\n\n原路径:{from}\n新路径:{to}\n\n迁移期间写操作会短暂排队。',
392
402
 
393
403
  // ── execution permission (PR #14) ─────────────────────────────────
394
404
  'form.field.permission': '执行权限',
@@ -422,6 +432,10 @@ export const zh = {
422
432
  'md.imageTitle': '点击查看大图 ({alt})',
423
433
  'md.lightboxAlt': '大图预览',
424
434
  'md.closePreview': '关闭预览',
435
+ 'image.add': '插入图片',
436
+ 'image.uploading': '正在上传…',
437
+ 'image.hint': '支持选择、粘贴或拖入 PNG/JPEG/GIF/WebP,单张不超过 5 MB',
438
+ 'image.defaultAlt': '图片',
425
439
 
426
440
  // ── slash completion popup (PR #14) ───────────────────────────────
427
441
  'slash.aria': '快捷命令与技能',
@@ -0,0 +1,29 @@
1
+ import type { AttachmentUpload } from '../shared/api.ts'
2
+
3
+ export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp'
4
+ const TYPES = new Set(IMAGE_ACCEPT.split(','))
5
+
6
+ /** Keep Markdown alt text single-line and unable to close its own bracket. */
7
+ export function imageAlt(fileName: string, fallback: string): string {
8
+ const withoutExtension = fileName.replace(/\.(?:png|jpe?g|gif|webp)$/i, '')
9
+ const clean = withoutExtension.replace(/[\[\]\r\n]/g, ' ').replace(/\s+/g, ' ').trim()
10
+ return clean.length > 0 ? clean.slice(0, 120) : fallback
11
+ }
12
+
13
+ export function imageMarkdown(asset: AttachmentUpload, alt: string): string {
14
+ return `![${alt}](${asset.url})`
15
+ }
16
+
17
+ /** Insert a block at the current selection, preserving readable line boundaries. */
18
+ export function insertImageMarkdown(value: string, start: number, end: number, markdown: string): { value: string; cursor: number } {
19
+ const before = value.slice(0, start)
20
+ const after = value.slice(end)
21
+ const prefix = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
22
+ const suffix = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
23
+ const inserted = `${prefix}${markdown}${suffix}`
24
+ return { value: before + inserted + after, cursor: before.length + inserted.length }
25
+ }
26
+
27
+ export function acceptedImageFiles(files: Iterable<File>): File[] {
28
+ return Array.from(files).filter(file => TYPES.has(file.type)).slice(0, 10)
29
+ }
@@ -398,6 +398,27 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
398
398
  .dsh-atb-bubble-meta span { font-size: 10.5px; color: var(--dsw-text-secondary, gray); }
399
399
  .dsh-atb-bubble-body { font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
400
400
 
401
+ .dsh-atb-markdown-body { white-space: pre-wrap; word-break: break-word; }
402
+ .dsh-atb-detail-img-wrap {
403
+ display: inline-flex; flex-direction: column; gap: 4px; max-width: min(100%, 520px); margin: 6px 8px 6px 0;
404
+ vertical-align: top;
405
+ }
406
+ .dsh-atb-detail-img {
407
+ display: block; max-width: 100%; max-height: 320px; object-fit: contain; border-radius: 8px; cursor: zoom-in;
408
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.22)); background: var(--dsw-bg-inset, rgba(128,128,128,.08));
409
+ }
410
+ .dsh-atb-detail-img-caption { font-size: 10.5px; color: var(--dsw-text-secondary, gray); overflow-wrap: anywhere; }
411
+ .dsh-atb-lightbox-backdrop {
412
+ position: fixed; inset: 0; z-index: 120; display: grid; place-items: center; padding: 28px;
413
+ background: rgba(0,0,0,.76); backdrop-filter: blur(3px);
414
+ }
415
+ .dsh-atb-lightbox-content { position: relative; max-width: 96vw; max-height: 92vh; }
416
+ .dsh-atb-lightbox-img { display: block; max-width: 96vw; max-height: 92vh; object-fit: contain; border-radius: 10px; }
417
+ .dsh-atb-lightbox-close {
418
+ position: absolute; top: -14px; right: -14px; width: 30px; height: 30px; border-radius: 999px; cursor: pointer;
419
+ border: 1px solid rgba(255,255,255,.38); background: rgba(20,20,20,.9); color: #fff;
420
+ }
421
+
401
422
  .dsh-atb-composer { display: flex; gap: 7px; align-items: flex-end; margin-top: 2px; }
402
423
  .dsh-atb-composer-input {
403
424
  flex: 1; font: inherit; font-size: 12.5px; line-height: 1.5; padding: 7px 10px; border-radius: 9px;
@@ -410,6 +431,14 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
410
431
  border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
411
432
  }
412
433
  .dsh-atb-composer-send:disabled { opacity: .4; cursor: default; }
434
+ .dsh-atb-image-actions { display: flex; align-items: center; gap: 8px; padding: 5px 1px 0; }
435
+ .dsh-atb-image-add {
436
+ flex: none; font: inherit; font-size: 11.5px; line-height: 1.4; padding: 4px 8px; border-radius: 7px; cursor: pointer;
437
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.3)); background: var(--dsw-bg-elevated, rgba(128,128,128,.08)); color: inherit;
438
+ }
439
+ .dsh-atb-image-add:hover:not(:disabled) { background: var(--dsw-bg-hover, rgba(128,128,128,.14)); }
440
+ .dsh-atb-image-add:disabled { opacity: .45; cursor: default; }
441
+ .dsh-atb-image-hint { font-size: 10.5px; color: var(--dsw-text-secondary, gray); }
413
442
 
414
443
  .dsh-atb-execlist { display: flex; flex-direction: column; gap: 5px; }
415
444
  .dsh-atb-exec-row {
@@ -854,9 +883,13 @@ color: var(--dsw-alias-state-business-primary, #3e63dd);
854
883
  .dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
855
884
  .dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
856
885
  /* ---------- 0.5.0 board settings ---------- */
857
- .dsh-atb-set { max-width: 460px; width: min(460px, 92vw); }
886
+ .dsh-atb-set { max-width: 620px; width: min(620px, 92vw); }
858
887
  .dsh-atb-set .dsh-atb-mode-picker { margin-top: 8px; }
859
888
  .dsh-atb-set .dsh-atb-isolation-note { margin-top: 10px; }
889
+ .dsh-atb-storage-path { width: 100%; margin-top: 10px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
890
+ .dsh-atb-storage-meta { display: grid; gap: 4px; margin-top: 8px; color: var(--dsh-atb-muted); font-size: 12px; overflow-wrap: anywhere; }
891
+ .dsh-atb-storage-error { color: var(--dsh-atb-danger); }
892
+ .dsh-atb-storage-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
860
893
 
861
894
  /* ---------- 0.5.5 SlashPromptInput & Permission Picker ---------- */
862
895
  .dsh-atb-perm-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
@@ -0,0 +1,120 @@
1
+ /** Durable, content-addressed image attachments for task descriptions/comments. */
2
+ import { createHash } from 'node:crypto'
3
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import type { StorageQueue } from './storage-queue.ts'
6
+
7
+ export const MAX_ASSET_BYTES = 5 * 1024 * 1024
8
+ export const MAX_ASSET_STORE_BYTES = 200 * 1024 * 1024
9
+ export const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000
10
+
11
+ export type ImageKind = { extension: 'png' | 'jpg' | 'gif' | 'webp'; mime: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' }
12
+ export type StoredAsset = ImageKind & { id: string; name: string; size: number; url: string }
13
+
14
+ const ASSET_NAME_RE = /^([a-f0-9]{64})\.(png|jpg|gif|webp)$/
15
+
16
+ /** Detect supported images from magic bytes; request MIME and filenames are not trusted. */
17
+ export function detectImage(bytes: Uint8Array): ImageKind | undefined {
18
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
19
+ && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
20
+ return { extension: 'png', mime: 'image/png' }
21
+ }
22
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
23
+ return { extension: 'jpg', mime: 'image/jpeg' }
24
+ }
25
+ if (bytes.length >= 6) {
26
+ const head = Buffer.from(bytes.subarray(0, 6)).toString('ascii')
27
+ if (head === 'GIF87a' || head === 'GIF89a') return { extension: 'gif', mime: 'image/gif' }
28
+ }
29
+ if (bytes.length >= 12 && Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF'
30
+ && Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') {
31
+ return { extension: 'webp', mime: 'image/webp' }
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ /** Files live outside the ledger so state/SSE/tool payloads only carry short Markdown URLs. */
37
+ export class AssetStore {
38
+ private queue: Promise<unknown> = Promise.resolve()
39
+ private root: string
40
+
41
+ constructor(root: string, private readonly now: () => number = () => Date.now(), private readonly storageQueue?: StorageQueue) { this.root = root }
42
+
43
+ /** Current absolute attachment-directory path. */
44
+ location(): string { return this.root }
45
+
46
+ /** Switch reads and future writes after the coordinator copied the directory. */
47
+ setLocation(root: string): void { this.root = root }
48
+
49
+ async put(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {
50
+ const run = () => this.putSerial(bytes, declaredMime)
51
+ if (this.storageQueue !== undefined) return this.storageQueue.run(run)
52
+ return (this.queue = this.queue.then(run, run)) as Promise<StoredAsset>
53
+ }
54
+
55
+ private async putSerial(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {
56
+ if (bytes.length === 0 || bytes.length > MAX_ASSET_BYTES) {
57
+ throw new Error(`image must be 1..${MAX_ASSET_BYTES} bytes`)
58
+ }
59
+ const kind = detectImage(bytes)
60
+ if (kind === undefined) throw new Error('unsupported image; use PNG, JPEG, GIF, or WebP')
61
+ if (declaredMime !== undefined && declaredMime.toLowerCase() !== kind.mime) {
62
+ throw new Error(`image content does not match ${declaredMime}`)
63
+ }
64
+ const id = createHash('sha256').update(bytes).digest('hex')
65
+ const name = `${id}.${kind.extension}`
66
+ await mkdir(this.root, { recursive: true })
67
+ try {
68
+ const current = await stat(join(this.root, name))
69
+ if (current.isFile()) return { id, name, size: current.size, url: `/dsh-taskboard/assets/${name}`, ...kind }
70
+ } catch { /* new content */ }
71
+
72
+ let total = 0
73
+ for (const entry of await readdir(this.root, { withFileTypes: true })) {
74
+ if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name)) continue
75
+ try { total += (await stat(join(this.root, entry.name))).size } catch { /* concurrent cleanup */ }
76
+ }
77
+ if (total + bytes.length > MAX_ASSET_STORE_BYTES) throw new Error('image store quota exceeded')
78
+ try {
79
+ await writeFile(join(this.root, name), bytes, { flag: 'wx' })
80
+ } catch (error) {
81
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
82
+ }
83
+ return { id, name, size: bytes.length, url: `/dsh-taskboard/assets/${name}`, ...kind }
84
+ }
85
+
86
+ async read(name: string): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> {
87
+ const run = async (): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> => {
88
+ const match = ASSET_NAME_RE.exec(name)
89
+ if (match === null) return undefined
90
+ const mime = match[2] === 'png' ? 'image/png'
91
+ : match[2] === 'jpg' ? 'image/jpeg'
92
+ : match[2] === 'gif' ? 'image/gif' : 'image/webp'
93
+ try {
94
+ return { bytes: await readFile(join(this.root, name)), mime }
95
+ } catch { return undefined }
96
+ }
97
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
98
+ }
99
+
100
+ /** Remove abandoned draft uploads after a grace period; referenced files always survive. */
101
+ async cleanup(referencedContent: string): Promise<number> {
102
+ const run = async (): Promise<number> => {
103
+ let entries
104
+ try { entries = await readdir(this.root, { withFileTypes: true }) } catch { return 0 }
105
+ let removed = 0
106
+ for (const entry of entries) {
107
+ if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name) || referencedContent.includes(entry.name)) continue
108
+ const path = join(this.root, entry.name)
109
+ try {
110
+ const info = await stat(path)
111
+ if (this.now() - info.mtimeMs < ORPHAN_GRACE_MS) continue
112
+ await rm(path, { force: true })
113
+ removed += 1
114
+ } catch { /* best effort */ }
115
+ }
116
+ return removed
117
+ }
118
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
119
+ }
120
+ }
@@ -49,6 +49,8 @@ import { createRepoScanner, type RepoScanner } from './repos.ts'
49
49
  import { activeHostLocale } from './locale.ts'
50
50
  import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
51
51
  import type { TemplateStore } from './templates.ts'
52
+ import { MAX_ASSET_BYTES, type AssetStore } from './assets.ts'
53
+ import type { StorageCoordinator } from './storage.ts'
52
54
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
53
55
  import type { TaskStore } from './store.ts'
54
56
  import { ERR, ToolError } from './tools.ts'
@@ -64,6 +66,7 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024
64
66
  const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`)
65
67
  const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`)
66
68
  const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`)
69
+ const ASSET_RE = new RegExp(`^${ROUTE_PREFIX}/assets/([a-f0-9]{64}\\.(?:png|jpg|gif|webp))$`)
67
70
 
68
71
  /** How long a workspace git-detection result stays cached (fail-soft). */
69
72
  const GIT_DETECT_TTL_MS = 60_000
@@ -91,6 +94,12 @@ export interface TaskboardRoutesOptions {
91
94
  scanner?: RepoScanner
92
95
  /** Task-template store (0.4.0); absent → 501 on template actions. */
93
96
  templates?: TemplateStore
97
+ /** Durable image attachment store (0.7.0); absent → attachment routes unavailable. */
98
+ assets?: AssetStore
99
+ /** Configurable data-directory coordinator (0.7.0). */
100
+ storage?: StorageCoordinator
101
+ /** Initial storage/ledger readiness barrier. */
102
+ ready?: () => Promise<void>
94
103
  /** Prompt completions face (0.5.5; dynamically discovers skills & commands). */
95
104
  promptCompletions?: () => Promise<{
96
105
  skills?: Array<{ name: string; description?: string }>
@@ -192,6 +201,19 @@ async function readBody(req: IncomingMessage): Promise<Record<string, unknown> |
192
201
  }
193
202
  }
194
203
 
204
+ /** Read one bounded binary upload without ever buffering beyond the file cap. */
205
+ async function readBytes(req: IncomingMessage, limit: number): Promise<Buffer> {
206
+ const chunks: Buffer[] = []
207
+ let total = 0
208
+ for await (const chunk of req) {
209
+ const bytes = chunk as Buffer
210
+ total += bytes.length
211
+ if (total > limit) throw new Error('body too large')
212
+ chunks.push(bytes)
213
+ }
214
+ return Buffer.concat(chunks)
215
+ }
216
+
195
217
  /** String field accessor (null when absent/not a string). */
196
218
  function str(body: Record<string, unknown>, key: string): string | null {
197
219
  const v = body[key]
@@ -378,6 +400,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
378
400
 
379
401
  // ---------------------------------------------------------------- GET
380
402
  if (req.method === 'GET') {
403
+ if (pathname === `${ROUTE_PREFIX}/storage`) {
404
+ if (options.storage === undefined) { res.writeHead(501); res.end(); return }
405
+ json(res, { ok: true, value: await options.storage.status() })
406
+ return
407
+ }
408
+ await options.ready?.()
409
+ const assetMatch = pathname.match(ASSET_RE)
410
+ if (assetMatch !== null) {
411
+ const asset = await options.assets?.read(assetMatch[1]!)
412
+ if (asset === undefined) { res.writeHead(404); res.end(); return }
413
+ res.writeHead(200, {
414
+ 'content-type': asset.mime,
415
+ 'content-length': asset.bytes.length,
416
+ 'cache-control': 'public, max-age=31536000, immutable',
417
+ 'x-content-type-options': 'nosniff',
418
+ })
419
+ res.end(asset.bytes)
420
+ return
421
+ }
381
422
  if (pathname === `${ROUTE_PREFIX}/state`) {
382
423
  await store.load()
383
424
  json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
@@ -527,6 +568,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
527
568
  res.end()
528
569
  return
529
570
  }
571
+ // Content-addressed image upload. The custom header makes this a
572
+ // non-simple cross-origin request, preserving the JSON routes' CSRF fence.
573
+ if (pathname === `${ROUTE_PREFIX}/assets`) {
574
+ await options.ready?.()
575
+ if (options.assets === undefined) {
576
+ const f = fail('invalid_input', 'image attachments unavailable')
577
+ json(res, f.res, 501)
578
+ return
579
+ }
580
+ if (req.headers['x-dsh-taskboard-upload'] !== '1') {
581
+ const f = fail('forbidden', 'missing upload header')
582
+ json(res, f.res, 403)
583
+ return
584
+ }
585
+ const declaredMime = String(req.headers['content-type'] ?? '').split(';', 1)[0]!.trim().toLowerCase()
586
+ try {
587
+ const bytes = await readBytes(req, MAX_ASSET_BYTES)
588
+ await options.assets.cleanup(JSON.stringify(store.snapshot()))
589
+ const asset = await options.assets.put(bytes, declaredMime)
590
+ json(res, { ok: true, value: asset }, 201)
591
+ } catch (error) {
592
+ const message = error instanceof Error ? error.message : String(error)
593
+ const status = message.includes('1..') ? 413 : message.includes('quota') ? 507 : 400
594
+ const f = fail('invalid_input', message)
595
+ json(res, f.res, status)
596
+ }
597
+ return
598
+ }
530
599
  // CSRF fence: cross-site simple requests cannot set application/json.
531
600
  const contentType = req.headers['content-type'] ?? ''
532
601
  if (!contentType.toLowerCase().startsWith('application/json')) {
@@ -548,6 +617,29 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
548
617
  return
549
618
  }
550
619
 
620
+ // Storage location is bootstrap metadata outside the ledger. Keep it on
621
+ // separate routes so JSON imports and whole-ledger replacement cannot
622
+ // redirect host filesystem writes.
623
+ if (pathname === `${ROUTE_PREFIX}/storage/check` || pathname === `${ROUTE_PREFIX}/storage/migrate`) {
624
+ if (options.storage === undefined) {
625
+ const f = fail('invalid_input', 'storage configuration unavailable')
626
+ json(res, f.res, 501)
627
+ return
628
+ }
629
+ try {
630
+ const directory = str(body, 'directory') ?? ''
631
+ const value = pathname.endsWith('/check')
632
+ ? await options.storage.check(directory)
633
+ : await options.storage.migrate(directory)
634
+ json(res, { ok: true, value })
635
+ } catch (error) {
636
+ const f = fail('invalid_input', error instanceof Error ? error.message : String(error))
637
+ json(res, f.res, f.status)
638
+ }
639
+ return
640
+ }
641
+ await options.ready?.()
642
+
551
643
  // ------------------------------------------------- POST /tasks (create)
552
644
  if (pathname === `${ROUTE_PREFIX}/tasks`) {
553
645
  try {
@@ -0,0 +1,10 @@
1
+ /** One process-wide serial queue shared by all taskboard persistence. */
2
+ export class StorageQueue {
3
+ private tail: Promise<unknown> = Promise.resolve()
4
+
5
+ run<T>(operation: () => Promise<T>): Promise<T> {
6
+ const result = this.tail.then(operation, operation)
7
+ this.tail = result.then(() => undefined, () => undefined)
8
+ return result
9
+ }
10
+ }