dsh-global-todo 0.14.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.16.0] — 2026-10-22
2
+
3
+ The three small promises the roadmap still carried.
4
+
5
+ - **`before:` / `after:` in the search box** bound the deadline (strict at the bound). Items
6
+ without a deadline cannot satisfy either bound, and a half-typed date such as `before:2026`
7
+ simply stops filtering instead of throwing on every keystroke.
8
+ - **「重新挂载」 next to a dead attachment** re-points it in place (`POST /relink`, same
9
+ `{ todo, changed }` shape as `/update`), keeping the kind and title. `annotateLinks()` finally
10
+ has its unit test — and it now flags a dead **image** too, which it used to skip, so a missing
11
+ screenshot shows the existing chip instead of a broken frame.
12
+ - **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
13
+ are list items, filter buttons report `aria-pressed`, and every icon-only button has a name.
14
+ `test/a11y.test.mjs` reads the client source so these cannot quietly regress without a browser.
15
+
16
+ ## [0.15.0] — 2026-10-22
17
+
18
+ Image attachments: a reference that renders, not a copy that goes stale.
19
+
20
+ - **`kind: "image"` is a third attachment kind** beside `url` and `file`. The path stays an
21
+ absolute reference — the list still never stores bytes — but it must have a renderable
22
+ extension (png / jpg / jpeg / webp / gif / avif; SVG is excluded because it can carry script
23
+ when served inline). A bare path is never guessed into an image: the caller states it.
24
+ - **`GET /asset?id=&index=` serves the bytes**, reading the path out of the store rather than from
25
+ the request, so there is no traversal surface. Capped at 8 MB with `nosniff`, and only for
26
+ `image` links — a document link asked through this route is a 400, a missing index a 404.
27
+ - **The panel shows a thumbnail on the row and a preview in the detail**, both lazy, and the
28
+ existing "file is gone" marker still applies: a dead image renders as a bad chip, not a broken
29
+ frame.
30
+ - Tool schemas accept `kind: "image"` wherever an attachment does, so an agent can attach a
31
+ screenshot of what it built.
32
+
1
33
  ## [0.14.0] — 2026-10-22
2
34
 
3
35
  Choose how much surface a deployment pays for.
package/README.md CHANGED
@@ -211,7 +211,10 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
211
211
  ### The panel
212
212
 
213
213
  - **Add** — type a title and press Enter. "More fields" reveals the description, the
214
- attachment box (one document path or URL per line), tags, priority and a due time.
214
+ attachment box (one document path or URL per line), tags, priority and a due time. An
215
+ attachment may also be `{ kind: "image", value: "/abs/shot.png" }` — the row then shows a
216
+ thumbnail and the detail a preview, with the bytes read on demand (png/jpg/jpeg/webp/gif/avif,
217
+ ≤8 MB) and never copied into the list.
215
218
  - **Filters** — all / open / doing / done / overdue, plus a text search across titles,
216
219
  descriptions, tags and attachments. The count chips in the header are filters too.
217
220
  - **Grouped by urgency** — overdue / today / next up / later / no due date / done, each
@@ -249,7 +252,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
249
252
  That is an attempt order: an unloadable target means the next one is tried. Anything with
250
253
  nowhere to go comes back with a picker rather than being sent somewhere unchosen, and one
251
254
  failure does not abort the batch.
252
- - **Search syntax** — `tag:release` (or `#ops`), `is:overdue`, `is:active`, `has:link`,
255
+ - **Search syntax** — `tag:release` (or `#ops`), `is:overdue`, `is:active`, `has:link`, `before:2026-11-01` / `after:2026-10-01` (deadline bounds; a half-typed date simply stops filtering),
253
256
  `has:note`, `level:p0`, plus free words combined with AND (which also match note text). A
254
257
  search without a colon behaves exactly as it always did.
255
258
  - **Progress notes** — an append-only trail per todo (count on the row, full text and an add
@@ -390,6 +393,8 @@ fence when that service is present, and by a strict loopback check otherwise.
390
393
  | POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
391
394
  | GET | `/api/global-todo/report?days=7` | what the agents finished in the window, and the files it touched |
392
395
  | GET | `/api/global-todo/session?id=` | what a bound conversation last said (`tail`, `alive`, `dir`) |
396
+ | POST | `/api/global-todo/relink` | `{ id, index, value }` — re-point a dead attachment (the value must still be a valid absolute path or URL of that kind) |
397
+ | GET | `/api/global-todo/asset?id=&index=` | the bytes of an **image** attachment (path read from the store, never from the request; ≤8 MB, png/jpg/jpeg/webp/gif/avif) |
393
398
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
394
399
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
395
400
  | POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }` — hand several todos over in one call; an id that matches nothing comes back instead of being sent elsewhere |
package/README.zh.md CHANGED
@@ -188,6 +188,7 @@ dsh plugin --profile web remove dsh-global-todo
188
188
 
189
189
  ### 面板
190
190
 
191
+ - **图片附件**:附件也可以写成 `{ "kind": "image", "value": "/绝对路径/shot.png" }`,行上显示缩略图、详情里显示预览;字节按需读取(png/jpg/jpeg/webp/gif/avif,≤8MB),**不复制进清单**。
191
192
  - **添加**:写一句标题回车即存。「更多字段」展开描述、附件框(一行一个文档绝对路径或网址)、
192
193
  标签、优先级、截止时间。
193
194
  - **筛选**:全部 / 未完成 / 进行中 / 已完成 / 逾期,外加跨标题、描述、标签、附件的文本搜索;
@@ -206,7 +207,7 @@ dsh plugin --profile web remove dsh-global-todo
206
207
  - **「抓取」在每个对话的分组上**:让**那个对话**里的 Agent 把该跟进的事登记成待办,结果就归在它自己那组下面(分组名旁边那个「抓取」)。工具栏上的「从本对话抓取」只在**当前对话还没有分组**时出现——否则没地方点它。抓取对象没在运行会**先唤醒**再问(与派发同一条规则)。
207
208
  - **工作目录**:`选择` 会用宿主的目录选择器;如果这个部署没挂载它(远程命名空间缺失),面板会自动改为**手动填路径**(`手动填` → 粘绝对路径 → `使用`),并说明原因——不会只丢一条看不懂的错误。
208
209
  - **一键分配(多选)**:工具栏点「选择」→ 勾几条 → 「一键分配」。每条按**原对话 → 同工作区对话 → 你选的目标**的顺序尝试(是**尝试顺序**,不是一次定死:原对话加载不了就自动往下走)。都找不到的会**报回来并给你选择器**,绝不擅自送到别处;其中一条失败也不会中断整批。
209
- - **搜索语法**:`tag:发布`(或 `#发布`)、`is:overdue`、`is:active`、`has:link`、`has:note`、`level:p0`(也可写 `level:urgent`),自由词之间是 **AND**,并且会一并搜进展记录。不带冒号的普通搜索行为不变。
210
+ - **搜索语法**:`tag:发布`(或 `#发布`)、`is:overdue`、`is:active`、`has:link`、`has:note`、`level:p0`(也可写 `level:urgent`)、`before:2026-11-01` / `after:2026-10-01`(按截止时间卡边界;日期没打完时只是不生效,不会报错),自由词之间是 **AND**,并且会一并搜进展记录。不带冒号的普通搜索行为不变。
210
211
  - **进展记录**:每条待办可追加「谁在什么时候说了什么」的流水(行上显示条数,详情里看全文并可直接补一条)。Agent 报「做不完、卡在哪」应该写在这里,**而不是改写你的描述**。
211
212
  - **失效附件会标出来**:文件被移走/删除的附件在行上标红、详情里注明;这个标记是**读取时算的**,不写进记录——文件回来了就不该再标红。
212
213
  - **新对话开在哪**:默认**继承写下这条待办的对话所在的工作区**(新会话的工作目录也就是那个工作区),所以「接着这个活干」不会跑到不相干的工作区去;想指定就选 `+ 新对话 · 工作区名`(列在工作区一栏里),指定的优先。只有当两者都不知道时,才退回按工作目录判断。
@@ -293,6 +294,8 @@ dsh plugin --profile web remove dsh-global-todo
293
294
  | POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
294
295
  | GET | `/api/global-todo/report?days=7` | 这段时间里 Agent 完成了什么、触及了哪些文件 |
295
296
  | GET | `/api/global-todo/session?id=` | 绑定的会话最后说了什么(`tail` / `alive` / `dir`) |
297
+ | POST | `/api/global-todo/relink` | `{ id, index, value }`——把失效附件重新指向(新值仍须是该类型的合法绝对路径/网址) |
298
+ | GET | `/api/global-todo/asset?id=&index=` | 读**图片附件**的字节(路径只从待办记录里取,不接受请求传路径;≤8MB,png/jpg/jpeg/webp/gif/avif) |
296
299
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
297
300
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
298
301
  | POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
package/docs/ROADMAP.md CHANGED
@@ -200,14 +200,14 @@ of conversations involved, files touched with counts, and how many wait for revi
200
200
  time, never stored. The panel shows it from `⋯ → 本周报告`. Tests:
201
201
  `test/markdown.test.mjs` (pure aggregation) and `test/routes.test.mjs`.
202
202
 
203
- ### 5. Image attachments
203
+ ### 5. ~~Image attachments~~ — landed in 0.15.0
204
204
 
205
- `kind: 'image'` beside `url` / `file`, so a screenshot can be attached instead of a path
206
- to one.
207
-
208
- - Today: `LINK_KINDS = ['url', 'file']` (`lib/links.js`); the link shape is already prepared.
209
- - Effort: medium — the store must not inline bytes, so this is a path with a preview and a
210
- clear "the file is gone" state, which #3-adjacent work already models.
205
+ `kind: 'image'` beside `url` / `file`: still an absolute reference (the store never holds
206
+ bytes), required to carry a renderable extension, with `GET /asset?id=&index=` reading the
207
+ path out of the record rather than from the request (8 MB cap, `nosniff`, image links only).
208
+ The row shows a thumbnail and the detail a preview; a dead image keeps the existing
209
+ missing-file chip instead of rendering a broken frame. `lib/links.js`, `lib/routes.js`,
210
+ `lib/client.js`; tests in `test/links.test.mjs` and `test/routes.test.mjs`.
211
211
 
212
212
  ### 6. Source adapters for the ingest door
213
213
 
@@ -218,11 +218,17 @@ to one.
218
218
  - Effort: large, and the mode of the work is different: every adapter is an external
219
219
  dependency with its own failure modes. Do it when a real source exists, not before.
220
220
 
221
- ### 7. Small, well-understood items
222
-
223
- - `before:` / `after:` date bounds in the search syntax (the parser already walks tokens).
224
- - 「重新挂载」 next to a dead attachment, plus the `annotateLinks()` unit test it owes.
225
- - A screen-reader pass over the panel and the group headers; the keyboard path is close.
221
+ ### 7. ~~Small, well-understood items~~ — landed in 0.16.0
222
+
223
+ - **`before:` / `after:` date bounds** in the search syntax, applied to the deadline and strict
224
+ at the bound (`lib/service.js`); a half-typed date stops filtering instead of erroring on
225
+ every keystroke.
226
+ - **「重新挂载」** beside a dead attachment (`POST /relink`, same shape as `/update`), plus the
227
+ `annotateLinks()` unit test the code owed — including the image kind, which the annotation
228
+ used to skip.
229
+ - **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
230
+ are list items, filter buttons report `aria-pressed`, and every icon-only button carries a
231
+ name (`test/a11y.test.mjs` guards all of it).
226
232
 
227
233
  ### Landed after this list was written
228
234
 
package/lib/client.js CHANGED
@@ -40,6 +40,11 @@ window.__ModuleLoader__.load({
40
40
  const LOCALE_NS = 'global-todo'
41
41
  /** Route prefix, relative to the document base. */
42
42
  const API = 'api/global-todo'
43
+ /**
44
+ * Where an image attachment's bytes come from. The route reads the path out of the
45
+ * store, so this URL carries an id and an index — never a filesystem path.
46
+ */
47
+ const assetUrl = (id, index) => `${API}/asset?id=${encodeURIComponent(id)}&index=${index}`
43
48
  const POLL_MS = 20000
44
49
  /** How long a deleted todo can be brought back. */
45
50
  const UNDO_MS = 8000
@@ -232,6 +237,7 @@ window.__ModuleLoader__.load({
232
237
  'dir.picked': '(已指定)',
233
238
  'action.save': '保存',
234
239
  'action.cancel': '取消',
240
+ 'action.relink': '重新挂载',
235
241
  'filter.all': '全部',
236
242
  'filter.active': '未完成',
237
243
  'filter.doing': '进行中',
@@ -534,6 +540,7 @@ window.__ModuleLoader__.load({
534
540
  'meta.origin': 'from {dir}',
535
541
  'meta.completed': 'Completed {when}',
536
542
  'action.save': 'Save',
543
+ 'action.relink': 'Re-point',
537
544
  'action.cancel': 'Cancel',
538
545
  'filter.all': 'All',
539
546
  'filter.active': 'Open',
@@ -1329,6 +1336,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1329
1336
  .gt-attach .gt-chip { max-width: 280px; color: var(--dsw-alias-label-secondary); }
1330
1337
  .gt-attach a.gt-chip:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
1331
1338
  .gt-attach-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1339
+ .gt-chip-img { padding: 2px 8px 2px 3px; }
1340
+ .gt-thumb { width: 16px; height: 16px; object-fit: cover; border-radius: 999px; display: block; }
1341
+ .gt-shot-wrap { display: inline-flex; }
1342
+ .gt-shot { width: 84px; height: 56px; object-fit: cover; border-radius: 6px;
1343
+ border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); }
1332
1344
  .gt-menu { position: absolute; top: 40px; right: 10px; z-index: 3; min-width: 184px; display: flex;
1333
1345
  flex-direction: column; padding: 5px; background: var(--dsw-alias-bg-overlay);
1334
1346
  border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; }
@@ -1367,6 +1379,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1367
1379
  .gt-pre { white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.6; }
1368
1380
  .gt-muted { color: var(--dsw-alias-label-secondary); }
1369
1381
  .gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
1382
+ .gt-relink { display: flex; flex: 1 1 100%; gap: 6px; align-items: center; }
1370
1383
  .gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
1371
1384
  .gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
1372
1385
  .gt-evidence { margin: 0; padding: 2px 0 2px 10px; border-left: 2px solid var(--dsw-alias-border-l2);
@@ -1722,6 +1735,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1722
1735
  // the only place the question "did it get anywhere" is asked.
1723
1736
  const [tail, setTail] = useState(null)
1724
1737
  const [linkValue, setLinkValue] = useState('')
1738
+ // Re-pointing a dead attachment: which row is editing, and what path it now holds.
1739
+ const [relinkIndex, setRelinkIndex] = useState(null)
1740
+ const [relinkValue, setRelinkValue] = useState('')
1725
1741
  const [linkTitle, setLinkTitle] = useState('')
1726
1742
  const [draft, setDraft] = useState({
1727
1743
  title: todo.title,
@@ -1935,7 +1951,21 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1935
1951
  'div',
1936
1952
  { className: 'gt-attach' },
1937
1953
  todo.links.slice(0, MAX_LINK_CHIPS).map((link, index) =>
1938
- link.kind === 'url'
1954
+ link.kind === 'image' && link.missing !== true
1955
+ ? h(
1956
+ 'a',
1957
+ {
1958
+ key: `${index}:${link.value}`,
1959
+ className: 'gt-chip gt-chip-img',
1960
+ href: assetUrl(todo.id, index),
1961
+ target: '_blank',
1962
+ rel: 'noreferrer noopener',
1963
+ title: link.value,
1964
+ },
1965
+ h('img', { className: 'gt-thumb', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
1966
+ h('span', { className: 'gt-attach-title' }, link.title),
1967
+ )
1968
+ : link.kind === 'url'
1939
1969
  ? h(
1940
1970
  'a',
1941
1971
  {
@@ -2376,6 +2406,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2376
2406
  h(
2377
2407
  'li',
2378
2408
  { className: 'gt-link', key: `${link.kind}:${link.value}` },
2409
+ link.kind === 'image' && link.missing !== true
2410
+ ? h(
2411
+ 'a',
2412
+ {
2413
+ className: 'gt-shot-wrap',
2414
+ href: assetUrl(todo.id, index),
2415
+ target: '_blank',
2416
+ rel: 'noreferrer noopener',
2417
+ title: link.value,
2418
+ },
2419
+ h('img', { className: 'gt-shot', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
2420
+ )
2421
+ : null,
2379
2422
  h(
2380
2423
  'span',
2381
2424
  { className: 'gt-link-main' },
@@ -2392,11 +2435,53 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2392
2435
  { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => copy(link.value) },
2393
2436
  copied === link.value ? t('action.copied') : t('action.copy'),
2394
2437
  ),
2438
+ link.missing === true
2439
+ ? h(
2440
+ 'button',
2441
+ {
2442
+ type: 'button',
2443
+ className: 'gt-btn gt-btn-quiet',
2444
+ disabled: busy,
2445
+ onClick: () => {
2446
+ setRelinkIndex(index)
2447
+ setRelinkValue(link.value)
2448
+ },
2449
+ },
2450
+ t('action.relink'),
2451
+ )
2452
+ : null,
2395
2453
  h(
2396
2454
  'button',
2397
2455
  { type: 'button', className: 'gt-btn gt-btn-quiet', disabled: busy, onClick: () => onAction('unlink', todo, { index }) },
2398
2456
  t('action.unlink'),
2399
2457
  ),
2458
+ relinkIndex === index
2459
+ ? h(
2460
+ 'span',
2461
+ { className: 'gt-relink' },
2462
+ h('input', {
2463
+ className: 'gt-input',
2464
+ value: relinkValue,
2465
+ placeholder: t('field.linkValue'),
2466
+ onChange: (event) => setRelinkValue(event.target.value),
2467
+ }),
2468
+ h(
2469
+ 'button',
2470
+ {
2471
+ type: 'button',
2472
+ className: 'gt-btn',
2473
+ disabled: busy || relinkValue.trim() === '',
2474
+ onClick: () => onAction('relink', todo, { index, value: relinkValue }),
2475
+ },
2476
+ t('action.save'),
2477
+ ),
2478
+ h(
2479
+ 'button',
2480
+ { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setRelinkIndex(null) },
2481
+ t('action.cancel'),
2482
+ ),
2483
+ )
2484
+ : null,
2400
2485
  ),
2401
2486
  ),
2402
2487
  ),
@@ -3487,6 +3572,13 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3487
3572
  if (kind === 'steps') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, steps: payload.steps } }))
3488
3573
  if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
3489
3574
  if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
3575
+ if (kind === 'relink') {
3576
+ return run(todo.id, async () => {
3577
+ await call('relink', { method: 'POST', body: { id: todo.id, ...payload } })
3578
+ setRelinkIndex(null)
3579
+ setRelinkValue('')
3580
+ })
3581
+ }
3490
3582
  if (kind === 'unlink') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, remove: [payload.index] } }))
3491
3583
  if (kind === 'remove') {
3492
3584
  return run(todo.id, async () => {
@@ -4745,7 +4837,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4745
4837
  const exemptGroup = typeof group.dir === 'string' && group.dir !== '' && exemptWorkspaces.includes(group.dir)
4746
4838
  return h(
4747
4839
  'div',
4748
- { className: 'gt-group', key: group.key, 'data-collapsed': isCollapsed ? '1' : '0' },
4840
+ { className: 'gt-group', key: group.key, role: 'listitem', 'data-collapsed': isCollapsed ? '1' : '0' },
4749
4841
  h(
4750
4842
  'div',
4751
4843
  { className: 'gt-group-head' },
@@ -4836,7 +4928,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4836
4928
  ? null
4837
4929
  : h(
4838
4930
  'div',
4839
- { className: 'gt-list' },
4931
+ { className: 'gt-list', role: 'list' },
4840
4932
  group.todos.map((todo) =>
4841
4933
  h(TodoItem, {
4842
4934
  selecting: selectMode,
@@ -4872,7 +4964,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4872
4964
  notice !== null && notice !== undefined
4873
4965
  ? h(
4874
4966
  'div',
4875
- { className: 'gt-toast' },
4967
+ { className: 'gt-toast', role: 'status', 'aria-live': 'polite' },
4876
4968
  h('span', null, notice.text),
4877
4969
  // Only offer the jump when the capability is really there: a button
4878
4970
  // that does nothing is worse than no button.
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
37
37
  import { createTools, normalizeToolGroups, selectTools } from './tools.js'
38
38
 
39
39
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
40
- export const VERSION = '0.14.0'
40
+ export const VERSION = '0.16.0'
41
41
 
42
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
43
43
  export const name = 'dsh-global-todo'
package/lib/links.js CHANGED
@@ -17,8 +17,28 @@
17
17
 
18
18
  import { TodoError } from './errors.js'
19
19
 
20
- /** Accepted attachment kinds. */
21
- export const LINK_KINDS = Object.freeze(['url', 'file'])
20
+ /**
21
+ * Accepted attachment kinds.
22
+ *
23
+ * `image` is a reference like `file` — the bytes stay where they are — but it is
24
+ * declared rather than inferred, because the panel renders it and a guess would
25
+ * turn a random document into a broken thumbnail. The list still never copies
26
+ * bytes; `GET /asset` reads the referenced file on demand.
27
+ */
28
+ export const LINK_KINDS = Object.freeze(['url', 'file', 'image'])
29
+
30
+ /** Extensions the panel can actually render. SVG is excluded: served inline from the same
31
+ * origin it can carry script. */
32
+ export const IMAGE_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'])
33
+
34
+ /**
35
+ * @param {string} value
36
+ * @returns {boolean} whether the path looks like a renderable image
37
+ */
38
+ export function isImagePath(value) {
39
+ const lower = String(value ?? '').toLowerCase()
40
+ return IMAGE_EXTENSIONS.some((extension) => lower.endsWith(extension))
41
+ }
22
42
 
23
43
  const MAX_URL = 2048
24
44
  const MAX_PATH = 4096
@@ -27,6 +47,8 @@ const MAX_TITLE = 200
27
47
  /**
28
48
  * Guess the kind of a bare string attachment.
29
49
  *
50
+ * `image` is never inferred: it is a rendering decision the caller states.
51
+ *
30
52
  * @param {string} value
31
53
  * @returns {'url' | 'file'}
32
54
  */
@@ -91,6 +113,9 @@ export function normalizeLink(input, options = {}) {
91
113
  if (!isAbsolutePath(value)) {
92
114
  throw new TodoError('invalid-link', `文档请给绝对路径(相对路径会在不同会话下指向不同文件):${value}`)
93
115
  }
116
+ if (kind === 'image' && !isImagePath(value)) {
117
+ throw new TodoError('invalid-link', `图片附件需要图片扩展名(${IMAGE_EXTENSIONS.join(' / ')}):${value}`)
118
+ }
94
119
  if (options.requireExistingFiles === true && typeof options.exists === 'function' && !options.exists(value)) {
95
120
  throw new TodoError('invalid-link', `找不到这个文件:${value}`)
96
121
  }
@@ -113,7 +138,7 @@ export function isAbsolutePath(value) {
113
138
  * @returns {string}
114
139
  */
115
140
  export function defaultLinkTitle(kind, value) {
116
- if (kind === 'file') {
141
+ if (kind === 'file' || kind === 'image') {
117
142
  const parts = value.split(/[\\/]/).filter(Boolean)
118
143
  return parts.length === 0 ? value : parts[parts.length - 1]
119
144
  }
package/lib/routes.js CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { createHash, timingSafeEqual } from 'node:crypto'
24
- import { readdir, stat } from 'node:fs/promises'
24
+ import { readFile, readdir, stat } from 'node:fs/promises'
25
25
  import { homedir } from 'node:os'
26
26
  import path from 'node:path'
27
27
 
@@ -81,6 +81,21 @@ export function isLoopbackRequest(request) {
81
81
  * @param {number} status
82
82
  * @param {unknown} body
83
83
  */
84
+ /** Cap for an inline image; a screenshot is a few hundred KB, a 4K render is not. */
85
+ const MAX_ASSET_BYTES = 8 * 1024 * 1024
86
+
87
+ /**
88
+ * @param {string} file
89
+ * @returns {string} the content type for a served image extension
90
+ */
91
+ function mimeForImage(file) {
92
+ const lower = String(file).toLowerCase()
93
+ if (lower.endsWith('.png')) return 'image/png'
94
+ if (lower.endsWith('.webp')) return 'image/webp'
95
+ if (lower.endsWith('.gif')) return 'image/gif'
96
+ if (lower.endsWith('.avif')) return 'image/avif'
97
+ return 'image/jpeg'
98
+ }
84
99
  function sendJson(response, status, body) {
85
100
  const payload = JSON.stringify(body)
86
101
  response.statusCode = status
@@ -403,6 +418,38 @@ export function createRoutes(deps) {
403
418
  * Derived, never stored: completion records already hold the facts, and a cached
404
419
  * aggregate would be a second truth to keep honest.
405
420
  */
421
+
422
+ /**
423
+ * Serve the bytes of an image attachment.
424
+ *
425
+ * The path comes from the store, never from the request: the caller names a todo and
426
+ * a link index, so there is no traversal surface here. The list still stores only a
427
+ * reference; this route reads the file when the panel needs a thumbnail.
428
+ */
429
+ exact('GET', `${ROUTE_BASE}/asset`, async (request, response) => {
430
+ const query = params(request)
431
+ const id = (query.get('id') ?? '').trim()
432
+ const index = Number.parseInt(query.get('index') ?? '', 10)
433
+ if (id === '' || !Number.isInteger(index) || index < 0) {
434
+ throw new TodoError('invalid-body', 'asset 需要 id 与 index')
435
+ }
436
+ const todo = await service.get(id)
437
+ const link = (todo.links ?? [])[index]
438
+ if (link === undefined) throw new TodoError('not-found', '这条待办没有这个附件')
439
+ if (link.kind !== 'image') throw new TodoError('invalid-link', '只有图片附件可以这样读取')
440
+ const info = await stat(link.value).catch(() => null)
441
+ if (info === null || info.isFile() !== true) throw new TodoError('not-found', '图片不在了')
442
+ if (info.size > MAX_ASSET_BYTES) {
443
+ throw new TodoError('payload-too-large', `图片超过 ${Math.round(MAX_ASSET_BYTES / 1024 / 1024)}MB`)
444
+ }
445
+ const bytes = await readFile(link.value)
446
+ response.statusCode = 200
447
+ response.setHeader('content-type', mimeForImage(link.value))
448
+ response.setHeader('cache-control', 'no-store')
449
+ response.setHeader('x-content-type-options', 'nosniff')
450
+ response.end(bytes)
451
+ }),
452
+
406
453
  exact('GET', `${ROUTE_BASE}/report`, async (request) => {
407
454
  const days = Number.parseInt(params(request).get('days') ?? '', 10)
408
455
  const todos = await service.listAll()
@@ -671,6 +718,28 @@ export function createRoutes(deps) {
671
718
  return { todo: await service.remove(body.id, { by: 'human' }) }
672
719
  }),
673
720
 
721
+ exact('POST', `${ROUTE_BASE}/relink`, async (request) => {
722
+ // Re-pointing a dead attachment: the byte-free equivalent of "upload it again".
723
+ const body = await readJsonBody(request)
724
+ if (typeof body.id !== 'string' || body.id.trim() === '') {
725
+ throw new TodoError('invalid-body', 'relink 需要 id')
726
+ }
727
+ const index = Number(body.index)
728
+ if (!Number.isInteger(index) || index < 0) {
729
+ throw new TodoError('invalid-body', 'relink 需要 index(附件在列表里的位置)')
730
+ }
731
+ const todo = await service.get(body.id)
732
+ const link = (todo.links ?? [])[index]
733
+ if (link === undefined) throw new TodoError('not-found', '这条待办没有这个附件')
734
+ if (typeof body.value !== 'string' || body.value.trim() === '') {
735
+ throw new TodoError('invalid-body', 'relink 需要新的 value(绝对路径或网址)')
736
+ }
737
+ const links = todo.links.slice()
738
+ links[index] = { kind: link.kind, value: body.value.trim(), ...(link.title === '' ? {} : { title: link.title }) }
739
+ // Same shape as /update: the service already answers `{ todo, changed }`.
740
+ return service.update(body.id, { links }, { by: 'human' })
741
+ }),
742
+
674
743
  exact('POST', `${ROUTE_BASE}/restore`, async (request) => {
675
744
  // The panel's undo: put a removed record back verbatim, timestamps and
676
745
  // attachments included, instead of re-creating it as a new todo.
package/lib/service.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  newId,
29
29
  normalizeActor,
30
30
  normalizeStored,
31
+ parseDueText,
31
32
  pushActivity,
32
33
  recordDispatch as recordDispatchOn,
33
34
  removeLink as removeLinkFrom,
@@ -132,8 +133,21 @@ export function createService(options) {
132
133
  * @param {string} raw
133
134
  * @returns {{ text: string[], tags: string[], levels: string[], flags: string[] }}
134
135
  */
136
+ /**
137
+ * A due-date bound from a `before:` / `after:` token.
138
+ *
139
+ * Only the explicit deadline shapes are accepted (date, date+time), because a search bound
140
+ * that silently became New Year's Day would filter the list without saying so.
141
+ *
142
+ * @param {string} text e.g. `2026-11-01` or `2026-11-01T18:00`
143
+ * @returns {number | null} epoch millis, or null when the token is not a date bound
144
+ */
145
+ function dueBound(text) {
146
+ const verdict = parseDueText(text)
147
+ return verdict.ok === true ? Date.parse(verdict.iso) : null
148
+ }
135
149
  function parseQuery(raw) {
136
- const parsed = { text: [], tags: [], levels: [], flags: [] }
150
+ const parsed = { text: [], tags: [], levels: [], flags: [], before: null, after: null }
137
151
  for (const token of String(raw).split(/\s+/)) {
138
152
  if (token === '') continue
139
153
  const lower = token.toLowerCase()
@@ -142,6 +156,17 @@ export function createService(options) {
142
156
  else if (lower.startsWith('level:') && lower.length > 6) {
143
157
  const value = lower.slice(6)
144
158
  parsed.levels.push(LEVEL_ALIAS[value] ?? value)
159
+ } else if (lower.startsWith('before:') && lower.length > 7) {
160
+ // A partial date is what a search box contains while it is being typed; turning that
161
+ // into an error would make the list flicker red on every keystroke. An unparseable
162
+ // bound is therefore ignored, and the token stays a plain search term.
163
+ const bound = dueBound(lower.slice(7))
164
+ if (bound === null) parsed.text.push(lower)
165
+ else parsed.before = bound
166
+ } else if (lower.startsWith('after:') && lower.length > 6) {
167
+ const bound = dueBound(lower.slice(6))
168
+ if (bound === null) parsed.text.push(lower)
169
+ else parsed.after = bound
145
170
  } else if (lower.startsWith('is:') && lower.length > 3) parsed.flags.push(lower)
146
171
  else if (lower.startsWith('has:') && lower.length > 4) parsed.flags.push(lower)
147
172
  else parsed.text.push(lower)
@@ -162,6 +187,14 @@ export function createService(options) {
162
187
  for (const level of parsed.levels) {
163
188
  if (todo.priority !== level) return false
164
189
  }
190
+ // A date bound is about the deadline. A todo without one cannot satisfy either bound:
191
+ // "what is due before Friday" has no meaningful answer for something with no deadline.
192
+ if (parsed.before !== null || parsed.after !== null) {
193
+ const due = typeof todo.dueAt === 'string' ? Date.parse(todo.dueAt) : Number.NaN
194
+ if (!Number.isFinite(due)) return false
195
+ if (parsed.before !== null && due >= parsed.before) return false
196
+ if (parsed.after !== null && due <= parsed.after) return false
197
+ }
165
198
  for (const flag of parsed.flags) {
166
199
  if (flag === 'is:overdue' && !isOverdue(todo, at)) return false
167
200
  if (flag === 'is:due' && (todo.dueAt === null || todo.dueAt === undefined)) return false
@@ -437,7 +470,9 @@ export function createService(options) {
437
470
  return {
438
471
  ...todo,
439
472
  links: todo.links.map((link) => {
440
- if (link.kind !== 'file') return { ...link }
473
+ // A dead image must be flagged exactly like a dead document: the panel would
474
+ // otherwise render a broken frame instead of the existing missing-file chip.
475
+ if (link.kind !== 'file' && link.kind !== 'image') return { ...link }
441
476
  let present = true
442
477
  try {
443
478
  present = check(link.value) !== false
package/lib/tools.js CHANGED
@@ -52,14 +52,14 @@ const STEPS_SHAPE = {
52
52
  const LINKS_INPUT = {
53
53
  type: 'array',
54
54
  description:
55
- '相关文档与地址。字符串写法按内容自动判别:http(s):// 开头算地址,其余必须是以 / 或盘符开头的绝对路径(文档原地引用,不复制内容)。也可给对象 { kind: "url"|"file", value, title }。',
55
+ '相关文档与地址。字符串写法按内容自动判别:http(s):// 开头算地址,其余必须是以 / 或盘符开头的绝对路径(文档原地引用,不复制内容)。也可给对象 { kind: "url"|"file"|"image", value, title };kind:"image" 用于截图,必须是图片路径(png/jpg/jpeg/webp/gif/avif),面板会显示缩略图。',
56
56
  items: {
57
57
  oneOf: [
58
58
  { type: 'string' },
59
59
  {
60
60
  type: 'object',
61
61
  properties: {
62
- kind: { type: 'string', enum: ['url', 'file'] },
62
+ kind: { type: 'string', enum: ['url', 'file', 'image'] },
63
63
  value: { type: 'string' },
64
64
  title: { type: 'string' },
65
65
  },
@@ -136,7 +136,7 @@ const TODO_SHAPE = { type: 'object',
136
136
  items: {
137
137
  type: 'object',
138
138
  properties: {
139
- kind: { type: 'string', enum: ['url', 'file'] },
139
+ kind: { type: 'string', enum: ['url', 'file', 'image'] },
140
140
  value: { type: 'string' },
141
141
  title: { type: 'string' },
142
142
  },
@@ -815,7 +815,7 @@ export function createTools(service, options = {}) {
815
815
  items: {
816
816
  type: 'object',
817
817
  properties: {
818
- kind: { type: 'string', enum: ['url', 'file'] },
818
+ kind: { type: 'string', enum: ['url', 'file', 'image'] },
819
819
  value: { type: 'string' },
820
820
  title: { type: 'string' },
821
821
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
6
6
  "type": "module",