dsh-global-todo 0.14.0 → 0.15.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,20 @@
1
+ ## [0.15.0] — 2026-10-22
2
+
3
+ Image attachments: a reference that renders, not a copy that goes stale.
4
+
5
+ - **`kind: "image"` is a third attachment kind** beside `url` and `file`. The path stays an
6
+ absolute reference — the list still never stores bytes — but it must have a renderable
7
+ extension (png / jpg / jpeg / webp / gif / avif; SVG is excluded because it can carry script
8
+ when served inline). A bare path is never guessed into an image: the caller states it.
9
+ - **`GET /asset?id=&index=` serves the bytes**, reading the path out of the store rather than from
10
+ the request, so there is no traversal surface. Capped at 8 MB with `nosniff`, and only for
11
+ `image` links — a document link asked through this route is a 400, a missing index a 404.
12
+ - **The panel shows a thumbnail on the row and a preview in the detail**, both lazy, and the
13
+ existing "file is gone" marker still applies: a dead image renders as a bad chip, not a broken
14
+ frame.
15
+ - Tool schemas accept `kind: "image"` wherever an attachment does, so an agent can attach a
16
+ screenshot of what it built.
17
+
1
18
  ## [0.14.0] — 2026-10-22
2
19
 
3
20
  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
@@ -390,6 +393,7 @@ 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
+ | 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
397
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
394
398
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
395
399
  | 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
  - **筛选**:全部 / 未完成 / 进行中 / 已完成 / 逾期,外加跨标题、描述、标签、附件的文本搜索;
@@ -293,6 +294,7 @@ 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
+ | GET | `/api/global-todo/asset?id=&index=` | 读**图片附件**的字节(路径只从待办记录里取,不接受请求传路径;≤8MB,png/jpg/jpeg/webp/gif/avif) |
296
298
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
297
299
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
298
300
  | POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
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
@@ -1329,6 +1334,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1329
1334
  .gt-attach .gt-chip { max-width: 280px; color: var(--dsw-alias-label-secondary); }
1330
1335
  .gt-attach a.gt-chip:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
1331
1336
  .gt-attach-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1337
+ .gt-chip-img { padding: 2px 8px 2px 3px; }
1338
+ .gt-thumb { width: 16px; height: 16px; object-fit: cover; border-radius: 999px; display: block; }
1339
+ .gt-shot-wrap { display: inline-flex; }
1340
+ .gt-shot { width: 84px; height: 56px; object-fit: cover; border-radius: 6px;
1341
+ border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); }
1332
1342
  .gt-menu { position: absolute; top: 40px; right: 10px; z-index: 3; min-width: 184px; display: flex;
1333
1343
  flex-direction: column; padding: 5px; background: var(--dsw-alias-bg-overlay);
1334
1344
  border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; }
@@ -1935,7 +1945,21 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1935
1945
  'div',
1936
1946
  { className: 'gt-attach' },
1937
1947
  todo.links.slice(0, MAX_LINK_CHIPS).map((link, index) =>
1938
- link.kind === 'url'
1948
+ link.kind === 'image' && link.missing !== true
1949
+ ? h(
1950
+ 'a',
1951
+ {
1952
+ key: `${index}:${link.value}`,
1953
+ className: 'gt-chip gt-chip-img',
1954
+ href: assetUrl(todo.id, index),
1955
+ target: '_blank',
1956
+ rel: 'noreferrer noopener',
1957
+ title: link.value,
1958
+ },
1959
+ h('img', { className: 'gt-thumb', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
1960
+ h('span', { className: 'gt-attach-title' }, link.title),
1961
+ )
1962
+ : link.kind === 'url'
1939
1963
  ? h(
1940
1964
  'a',
1941
1965
  {
@@ -2376,6 +2400,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2376
2400
  h(
2377
2401
  'li',
2378
2402
  { className: 'gt-link', key: `${link.kind}:${link.value}` },
2403
+ link.kind === 'image' && link.missing !== true
2404
+ ? h(
2405
+ 'a',
2406
+ {
2407
+ className: 'gt-shot-wrap',
2408
+ href: assetUrl(todo.id, index),
2409
+ target: '_blank',
2410
+ rel: 'noreferrer noopener',
2411
+ title: link.value,
2412
+ },
2413
+ h('img', { className: 'gt-shot', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
2414
+ )
2415
+ : null,
2379
2416
  h(
2380
2417
  'span',
2381
2418
  { className: 'gt-link-main' },
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.15.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()
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.15.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",