dsh-global-todo 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.7.0] — 2026-10-20
2
+
3
+ An agent's "done" becomes a claim a person confirms, instead of a fact.
4
+
5
+ - **Completing as an agent now leaves a review mark.** `todo.review = { pending, at, by }` is set
6
+ by the model whenever the actor is `agent` (both `setStatus … done` and the completion path the
7
+ tools use); a person finishing something sets none, and anything that leaves `done` clears it.
8
+ - **`POST /api/global-todo/review`** answers it: `confirm` keeps the todo finished and drops the
9
+ mark, `reject` puts it back on the list with the completion record removed — "not actually done"
10
+ is the only honest reading of a rejected claim. Answering twice is refused, not silently
11
+ accepted.
12
+ - **The panel shows it**: a `待确认` chip on the row, a `待确认` filter that is the done list
13
+ narrowed to pending marks, and `确认完成` / `退回清单` in the row menu. `counts.review` drives the
14
+ chip and the filter.
15
+ - **Agents see it too**: `presentTodo` carries `review`, so an agent can tell that its completion
16
+ is still waiting rather than assuming it landed.
17
+ - Two new model tests cover the rule (agent vs person, confirm vs reject, no double answers).
18
+
19
+ ## [0.6.8] — 2026-10-20
20
+
21
+ Every conditional state now has a baseline image, and the harness refuses to render a blank
22
+ one.
23
+
24
+ - **Four new preview scenarios**: empty list, filter with no match, inbox, and a due reminder.
25
+ `tools/preview.mjs` grew a `SCENARIOS` table the fake fetch reads, so a state that used to be
26
+ reachable only by clicking can be rendered and photographed like any other.
27
+ - **`npm run screenshots` writes eight images** (list, detail, selection, settings + the four
28
+ above). Both READMEs index them in a state table instead of embedding all eight.
29
+ - **`npm run check:render` asserts each state's marker** (`记第一条`, `清除筛选`, the inbox
30
+ explanation, the reminder title), so a regression shows up as a failing check rather than a
31
+ silently blank picture.
32
+
1
33
  ## [0.6.7] — 2026-10-20
2
34
 
3
35
  Copy safety, and the last orphan of the old footer.
package/README.md CHANGED
@@ -35,6 +35,17 @@ one record:
35
35
 
36
36
  ![Settings: grouped fields, hints under each label, one save row](docs/screenshots/panel-settings.png)
37
37
 
38
+ Every state that only appears under a condition has a fixed baseline image, rendered by the same
39
+ preview and photographed by the same tool:
40
+
41
+ | State | Image |
42
+ |---|---|
43
+ | Empty list | [`panel-empty.png`](docs/screenshots/panel-empty.png) |
44
+ | Filter with no match | [`panel-filtered.png`](docs/screenshots/panel-filtered.png) |
45
+ | Inbox (awaiting confirmation) | [`panel-inbox.png`](docs/screenshots/panel-inbox.png) |
46
+ | Due reminder | [`panel-reminders.png`](docs/screenshots/panel-reminders.png) |
47
+ | Selection mode | [`panel-select.png`](docs/screenshots/panel-select.png) |
48
+
38
49
  These are produced by `npm run screenshots` (which chains `npm run preview`): the real client
39
50
  half is rendered to static HTML with no Host and no dependencies, then photographed at 1280×720
40
51
  into `docs/screenshots/`. Playwright is not a dependency — the tool borrows the copy a DSH
package/README.zh.md CHANGED
@@ -32,6 +32,16 @@ DSH 自带的 `todo_write` 属于**某一个 agent 会话**:整表替换、三
32
32
 
33
33
  ![设置:分组字段、说明写在标签下方、底部一行保存](docs/screenshots/panel-settings.png)
34
34
 
35
+ 每个「只在特定情况下出现」的面板状态都有一张固定基线图,由同一套预览渲染、同一支工具拍摄,改样式时可以直接对比:
36
+
37
+ | 状态 | 图片 |
38
+ |---|---|
39
+ | 清单为空 | [`panel-empty.png`](docs/screenshots/panel-empty.png) |
40
+ | 筛选无结果 | [`panel-filtered.png`](docs/screenshots/panel-filtered.png) |
41
+ | 收件箱(待确认) | [`panel-inbox.png`](docs/screenshots/panel-inbox.png) |
42
+ | 到点提醒 | [`panel-reminders.png`](docs/screenshots/panel-reminders.png) |
43
+ | 批量选择 | [`panel-select.png`](docs/screenshots/panel-select.png) |
44
+
35
45
  图都由 `npm run screenshots`(内部串起 `npm run preview`)生成:先把真实的客户端半区渲染成静态
36
46
  HTML(不依赖宿主、零依赖),再以 1280×720 拍进 `docs/screenshots/`。Playwright 不是依赖——工具
37
47
  借的是 DSH profile 里已经装好的那份,或 `DSH_PLAYWRIGHT` 指向的那份。
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/client.js CHANGED
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
50
50
  * different mechanisms (restart vs. hot reload), so "which one am I looking
51
51
  * at" needs an answer that the bundle itself carries.
52
52
  */
53
- const UI_REV = 'ui43'
53
+ const UI_REV = 'ui45'
54
54
  /** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
55
55
  let activeLang = 'zh'
56
56
 
@@ -107,6 +107,12 @@ window.__ModuleLoader__.load({
107
107
  'settings.saved': '设置已保存',
108
108
  'settings.about': '关于',
109
109
  'action.keys': '快捷键',
110
+ 'review.confirm': '确认完成',
111
+ 'review.reject': '退回清单',
112
+ 'review.badge': '待确认',
113
+ 'review.hint': 'Agent 标记完成,等你确认;确认后才算真的完成。',
114
+ 'review.explain': '以下由 Agent 标记完成,确认后才算真的完成。',
115
+ 'filter.review': '待确认',
110
116
  'keys.search': '聚焦搜索',
111
117
  'keys.new': '开始记一条',
112
118
  'keys.save': '保存这条(在标题里)',
@@ -373,6 +379,12 @@ window.__ModuleLoader__.load({
373
379
  'settings.saved': 'Settings saved',
374
380
  'settings.about': 'About',
375
381
  'action.keys': 'Keyboard shortcuts',
382
+ 'review.confirm': 'Confirm done',
383
+ 'review.reject': 'Send back',
384
+ 'review.badge': 'Needs review',
385
+ 'review.hint': 'The agent says this is done; confirm it or send it back.',
386
+ 'review.explain': 'Finished by the agent. Confirm before it counts as done.',
387
+ 'filter.review': 'Review',
376
388
  'keys.search': 'Focus the search box',
377
389
  'keys.new': 'Start a new todo',
378
390
  'keys.save': 'Save it (from the title field)',
@@ -1210,6 +1222,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1210
1222
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1211
1223
  .gt-truncated { color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
1212
1224
  .gt-bulk-tag { flex: 0 1 120px; min-width: 90px; }
1225
+ .gt-review { padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
1226
+ color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary); }
1213
1227
  .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
1214
1228
  color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
1215
1229
  vertical-align: 1px; }
@@ -1674,6 +1688,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1674
1688
  if (todo.completion !== null && todo.completion !== undefined) {
1675
1689
  meta.push(h('span', { key: 'completed', title: todo.completion.summary }, t('meta.completed', { when: absoluteTime(todo.completion.at) })))
1676
1690
  }
1691
+ if (todo.review?.pending === true) {
1692
+ meta.push(h('span', { key: 'review', className: 'gt-review', title: t('review.hint') }, t('review.badge')))
1693
+ }
1677
1694
  meta.push(h('span', { key: 'by' }, t(`meta.by.${todo.createdBy === 'agent' ? 'agent' : 'human'}`)))
1678
1695
  if (todo.sessionId !== '') {
1679
1696
  const stranded = todo.sessionAlive === true && (todo.sessionDir ?? '') === ''
@@ -1884,6 +1901,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1884
1901
  h(Menu, {
1885
1902
  label: t('action.menu'),
1886
1903
  items: [
1904
+ ...(todo.review?.pending === true
1905
+ ? [
1906
+ { key: 'confirmReview', label: t('review.confirm'), icon: 'check', onSelect: () => onAction('review-confirm', todo) },
1907
+ { key: 'rejectReview', label: t('review.reject'), icon: 'refresh', danger: true, onSelect: () => onAction('review-reject', todo) },
1908
+ ]
1909
+ : []),
1887
1910
  ...(todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
1888
1911
  ? [
1889
1912
  { key: 'confirm', label: t('action.confirm'), icon: 'check', onSelect: () => onAction('confirm', todo) },
@@ -2253,7 +2276,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2253
2276
  const [state, setState] = useState({ todos: [], counts: null, health: null, matched: 0, truncated: false })
2254
2277
  const [loading, setLoading] = useState(true)
2255
2278
  const [error, setError] = useState(null)
2256
- const [filter, setFilter] = useState('active')
2279
+ // The static preview asks for a starting view it cannot click its way into.
2280
+ const [filter, setFilter] = useState(typeof props.initialFilter === 'string' ? props.initialFilter : 'active')
2257
2281
  const [query, setQuery] = useState('')
2258
2282
  // Multi-select for batch assignment. A separate mode, because the round control on
2259
2283
  // each row already means "done".
@@ -2487,8 +2511,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2487
2511
  const reload = useCallback(
2488
2512
  async (next = {}) => {
2489
2513
  const selected = next.filter ?? filter
2490
- // The inbox is a tag view over active records, not a status of its own.
2491
- const status = selected === 'inbox' ? 'active' : selected
2514
+ // Two views sit on top of a status instead of being one: the inbox is a tag over
2515
+ // active records, and review is "done, but a person has not agreed yet".
2516
+ const status = selected === 'inbox' ? 'active' : selected === 'review' ? 'done' : selected
2492
2517
  const search = next.query ?? query
2493
2518
  try {
2494
2519
  const data = await call('list', {
@@ -2501,7 +2526,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2501
2526
  if (!mounted.current) return
2502
2527
  setState((current) => ({
2503
2528
  ...current,
2504
- todos: data.todos,
2529
+ // The review view is the done list narrowed to records whose agent
2530
+ // completion nobody has answered yet.
2531
+ todos:
2532
+ selected === 'review'
2533
+ ? data.todos.filter((todo) => todo.review?.pending === true)
2534
+ : data.todos,
2505
2535
  counts: data.counts,
2506
2536
  matched: Number.isInteger(data.matched) ? data.matched : data.todos.length,
2507
2537
  truncated: data.truncated === true,
@@ -3143,7 +3173,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3143
3173
  }
3144
3174
 
3145
3175
  const onAction = (kind, todo, payload) => {
3146
- if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
3176
+ if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done', by: 'human' } }))
3177
+ // Answering an agent's completion. Confirming keeps it done; rejecting sends it back
3178
+ // to the list, because "not actually done" is the only honest reading.
3179
+ if (kind === 'review-confirm') {
3180
+ return run(todo.id, () => call('review', { method: 'POST', body: { id: todo.id, decision: 'confirm' } }))
3181
+ }
3182
+ if (kind === 'review-reject') {
3183
+ return run(todo.id, () => call('review', { method: 'POST', body: { id: todo.id, decision: 'reject' } }))
3184
+ }
3147
3185
  if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
3148
3186
  if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
3149
3187
  // Confirming keeps the record on the list; discarding archives it. Both only remove
@@ -3765,6 +3803,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3765
3803
  // day one is furniture, not a control.
3766
3804
  ...(counts.archived > 0 ? [['archived', counts.archived]] : []),
3767
3805
  ...(counts.inbox > 0 ? [['inbox', counts.inbox]] : []),
3806
+ ...(counts.review > 0 ? [['review', counts.review]] : []),
3768
3807
  ].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
3769
3808
  ),
3770
3809
  h('span', { className: 'gt-divider' }),
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
36
36
  import { createTools } from './tools.js'
37
37
 
38
38
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
39
- export const VERSION = '0.6.7'
39
+ export const VERSION = '0.7.0'
40
40
 
41
41
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
42
42
  export const name = 'dsh-global-todo'
package/lib/model.js CHANGED
@@ -217,6 +217,9 @@ export function createTodo(input, options = {}) {
217
217
  // beside the status rather than inside it, because "done" alone says
218
218
  // nothing about what was done.
219
219
  completion: null,
220
+ // Set when an agent finishes something: the person who owns the list confirms it or
221
+ // sends it back. An agent's "done" is a claim, not a decision.
222
+ review: null,
220
223
  // An append-only trail of what happened while the todo was open. Unlike the
221
224
  // description (the user's own text) this is where an agent reports progress or
222
225
  // says it is blocked, so reporting never has to rewrite someone's words.
@@ -312,6 +315,7 @@ export function normalizeStored(raw, options = {}) {
312
315
  originSessionId: typeof raw.originSessionId === 'string' ? raw.originSessionId : '',
313
316
  lastDispatch: raw.lastDispatch !== null && typeof raw.lastDispatch === 'object' ? raw.lastDispatch : null,
314
317
  completion: normalizeCompletion(raw.completion),
318
+ review: normalizeReview(raw.review),
315
319
  notes: normalizeNotes(raw.notes),
316
320
  activity: Array.isArray(raw.activity)
317
321
  ? raw.activity
@@ -328,6 +332,64 @@ export function normalizeStored(raw, options = {}) {
328
332
  return todo
329
333
  }
330
334
 
335
+ /**
336
+ * A stored review mark, repaired rather than rejected.
337
+ *
338
+ * Only one shape exists today — an agent finished this and the owner has not answered —
339
+ * so anything else is dropped instead of guessed at.
340
+ *
341
+ * @param {unknown} raw
342
+ * @returns {{ pending: true, at: number, by: string } | null}
343
+ */
344
+ function normalizeReview(raw) {
345
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
346
+ if (raw.pending !== true) return null
347
+ return {
348
+ pending: true,
349
+ at: Number.isFinite(raw.at) ? Number(raw.at) : Date.now(),
350
+ by: normalizeActor(raw.by),
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Answer the question an agent left behind.
356
+ *
357
+ * Confirm keeps the todo finished and drops the mark; reject puts it back on the list,
358
+ * because "not actually done" is the only honest reading of a rejected claim.
359
+ *
360
+ * @param {object} todo
361
+ * @param {'confirm' | 'reject'} decision
362
+ * @param {{ now?: number, by?: 'human' | 'agent', reason?: string }} [options]
363
+ * @returns {boolean} whether the record changed
364
+ */
365
+ export function reviewTodo(todo, decision, options = {}) {
366
+ const now = options.now ?? Date.now()
367
+ const by = normalizeActor(options.by ?? 'human')
368
+ const next = normalizeEnum(decision, ['confirm', 'reject'], 'invalid-decision', '决定', 'confirm')
369
+ if (todo.review === null || todo.review === undefined) return false
370
+ todo.review = null
371
+ todo.updatedAt = now
372
+ todo.updatedBy = by
373
+ const reason = typeof options.reason === 'string' ? options.reason.trim().slice(0, 120) : ''
374
+ if (next === 'reject') {
375
+ todo.status = 'open'
376
+ todo.doneAt = null
377
+ todo.completion = null
378
+ pushActivity(
379
+ todo,
380
+ { kind: 'reopened', by, at: now, summary: reason === '' ? '确认未完成,退回清单' : reason },
381
+ options.activityLimit ?? LIMITS.activity,
382
+ )
383
+ return true
384
+ }
385
+ pushActivity(
386
+ todo,
387
+ { kind: 'reviewed', by, at: now, summary: reason === '' ? '确认完成' : reason },
388
+ options.activityLimit ?? LIMITS.activity,
389
+ )
390
+ return true
391
+ }
392
+
331
393
  /**
332
394
  * A stored completion record, repaired rather than rejected.
333
395
  *
@@ -428,6 +490,7 @@ export function completeTodo(todo, evidence = {}, options = {}) {
428
490
  todo.doneAt = now
429
491
  todo.updatedAt = now
430
492
  todo.updatedBy = by
493
+ todo.review = by === 'agent' ? { pending: true, at: now, by } : null
431
494
  todo.completion = {
432
495
  at: now,
433
496
  by,
@@ -546,6 +609,9 @@ export function setStatus(todo, status, options = {}) {
546
609
  // A completion record describes a finished job, so reopening clears it: a
547
610
  // record that still claims the work is done is worse than no record.
548
611
  if (next !== 'done') todo.completion = null
612
+ // Who finished it decides whether a person still has to agree: an agent's "done" is a
613
+ // claim, the owner's is a decision. Leaving done drops the mark with the record.
614
+ todo.review = next === 'done' && by === 'agent' ? { pending: true, at: now, by } : null
549
615
  todo.updatedAt = now
550
616
  todo.updatedBy = by
551
617
  const kind = next === 'done' ? 'done' : next === 'open' ? 'reopened' : 'status'
@@ -666,7 +732,19 @@ export function isOverdue(todo, now = Date.now()) {
666
732
  * @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, archived: number, overdue: number }}
667
733
  */
668
734
  export function counts(todos, now = Date.now()) {
669
- const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
735
+ const out = {
736
+ total: todos.length,
737
+ open: 0,
738
+ doing: 0,
739
+ done: 0,
740
+ cancelled: 0,
741
+ archived: 0,
742
+ overdue: 0,
743
+ inbox: 0,
744
+ // Finished by an agent and not yet confirmed by a person. Counted separately from
745
+ // done because "the agent says so" is not the same fact as "a person agreed".
746
+ review: 0,
747
+ }
670
748
  for (const todo of todos) {
671
749
  if (todo.status in out) out[todo.status] += 1
672
750
  if (isOverdue(todo, now)) out.overdue += 1
@@ -675,6 +753,7 @@ export function counts(todos, now = Date.now()) {
675
753
  if ((todo.status === 'open' || todo.status === 'doing') && (todo.tags ?? []).some((tag) => tag.toLowerCase() === 'inbox')) {
676
754
  out.inbox += 1
677
755
  }
756
+ if (todo.status === 'done' && todo.review?.pending === true) out.review += 1
678
757
  }
679
758
  return out
680
759
  }
package/lib/routes.js CHANGED
@@ -578,6 +578,12 @@ export function createRoutes(deps) {
578
578
  return { todo: await service.setStatus(body.id, body.status ?? 'done', { by }) }
579
579
  }),
580
580
 
581
+ // Answering the mark an agent's completion leaves behind: confirm, or send it back.
582
+ exact('POST', `${ROUTE_BASE}/review`, async (request) => {
583
+ const body = await readJsonBody(request)
584
+ return { todo: await service.review(body.id, body.decision ?? 'confirm', { by: 'human', reason: body.reason }) }
585
+ }),
586
+
581
587
  exact('POST', `${ROUTE_BASE}/remove`, async (request) => {
582
588
  const body = await readJsonBody(request)
583
589
  return { todo: await service.remove(body.id, { by: 'human' }) }
package/lib/service.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  pushActivity,
32
32
  recordDispatch as recordDispatchOn,
33
33
  removeLink as removeLinkFrom,
34
+ reviewTodo,
34
35
  setStatus as setStatusOn,
35
36
  } from './model.js'
36
37
 
@@ -318,6 +319,33 @@ export function createService(options) {
318
319
  })
319
320
  }
320
321
 
322
+ /**
323
+ * Answer the review mark an agent's completion left behind.
324
+ *
325
+ * Kept separate from {@link setStatus} because it is a different decision: the record is
326
+ * already done, and the owner is either agreeing or sending it back — not changing a
327
+ * status that nobody asked about. A record without a pending mark is refused, so the
328
+ * panel cannot confirm something twice.
329
+ *
330
+ * @param {string} id
331
+ * @param {'confirm' | 'reject'} decision
332
+ * @param {{ by?: 'human' | 'agent', reason?: string }} [context]
333
+ * @returns {Promise<object>} the todo after the decision
334
+ */
335
+ async function review(id, decision, context = {}) {
336
+ return store.update((doc) => {
337
+ const todo = requireTodo(doc, id)
338
+ if (todo.review?.pending !== true) {
339
+ throw new TodoError('invalid-status', '这条待办没有等你确认的完成标记')
340
+ }
341
+ const changed = reviewTodo(todo, decision, {
342
+ ...modelOptions(context.by ?? 'human'),
343
+ reason: context.reason,
344
+ })
345
+ return changed ? { next: doc.todos, result: todo } : { result: todo }
346
+ })
347
+ }
348
+
321
349
  /**
322
350
  * Put a wrongly-finished todo back on the list.
323
351
  *
@@ -763,6 +791,7 @@ export function createService(options) {
763
791
  create,
764
792
  update,
765
793
  setStatus,
794
+ review,
766
795
  reopen,
767
796
  complete,
768
797
  note,
package/lib/tools.js CHANGED
@@ -162,6 +162,9 @@ export function presentTodo(todo, now = Date.now()) {
162
162
  originDir: todo.originDir ?? '',
163
163
  originSessionId: todo.originSessionId ?? '',
164
164
  notes: (todo.notes ?? []).map((entry) => ({ at: entry.at, by: entry.by, text: entry.text })),
165
+ // Present so an agent can see it left a claim waiting: a record an agent finished is
166
+ // not settled until a person confirms it, and saying so beats assuming it landed.
167
+ review: todo.review?.pending === true ? { pending: true, at: todo.review.at, by: todo.review.by } : null,
165
168
  completion:
166
169
  todo.completion === null || todo.completion === undefined
167
170
  ? null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
6
6
  "type": "module",