@ssamurias/dsh-fish 0.9.1 → 0.9.3

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/lib/index.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * dispose → 停定时器、注销路由。停用插件 = 零后台活动。
11
11
  */
12
12
 
13
+ import { setTimeout as sleep } from 'node:timers/promises'
14
+
13
15
  import { rootDir, readEvents, appendEvent, readJson, paths, writeAtomic, ensureHer } from './store.js'
14
16
  import { loadConfig } from './settings.js'
15
17
  import { deriveSelf } from './derive.js'
@@ -23,6 +25,7 @@ import { registerLetter } from './letter.js'
23
25
  import { registerNote } from './note.js'
24
26
  import { registerBringBack } from './keep.js'
25
27
  import { registerAsk } from './ask.js'
28
+ import { answerCard } from './cardanswer.js'
26
29
  import { registerDream } from './dream.js'
27
30
  import { registerProactive } from './proactive.js'
28
31
  import { registerObserve } from './observe.js'
@@ -38,6 +41,7 @@ import { registerToolRestrict } from './restrict.js'
38
41
  // 停用时不清的话下次启用会带着上一次的残留(2026-10-25)。
39
42
  import { clearWakeSection, registerIdentity } from './identity.js'
40
43
  import { registerPossession } from './possess.js'
44
+ import { withUseDuringRegistration } from './use.js'
41
45
 
42
46
  import { checkUpgrade } from './upgrade.js'
43
47
  import { checkHost, readHostVersion } from './hostver.js'
@@ -45,8 +49,11 @@ import { logBoot, makeLogger } from './log.js'
45
49
 
46
50
  export const name = 'dsh-fish'
47
51
 
48
- /** 版本号:只用于日志导出(排查时第一件事是确认跑的是哪一版)。 */
49
- export const VERSION = '0.9.1'
52
+ /**
53
+ * 版本号,必须和 package.json 的 version 一样(test/boot.test.mjs 盯着)。
54
+ * 不只是日志:升级自检和设置页的「检查更新」拿它当「正在跑的版本」。
55
+ */
56
+ export const VERSION = '0.9.3'
50
57
 
51
58
  // ⚠️ 一个数组写全,不要拆成对象
52
59
  // 2026-10-30(m02961):`userQuestions` **不再写进 inject**。写进 inject 的东西缺一个,
@@ -81,6 +88,11 @@ export function apply(ctx, baseConfig = {}) {
81
88
  // 写进 inject 的代价是「缺了就整个插件不装」——而缺的那个服务只关掉一条功能。
82
89
  const compat = { checked: false, userQuestions: false }
83
90
  let heartbeat = null
91
+ let stopped = false
92
+ // 正在认领的那次:shutdown 要等它放开,新实例才不会把旧认领读成「被占着」
93
+ let claiming = null
94
+ // 在途的那次心跳:放开之前要等它,不然它会把放开的认领写回去
95
+ let beating = null
84
96
 
85
97
  async function boot() {
86
98
  logBoot(root, `${VERSION} · ${process.version} · ${process.platform}`)
@@ -119,21 +131,24 @@ export function apply(ctx, baseConfig = {}) {
119
131
  // 她自己的房间必须先存在:她的会话 cwd 指向这里(不是 root),
120
132
  // 这样她不会一睁眼就站在自己的 events.jsonl 旁边。
121
133
  const herDir = await ensureHer(root)
134
+ if (stopped) return
122
135
 
123
136
  // ---- 一个 root 只认一个实例(2026-10-28 m01319)----
124
137
  // root 是进程级解析的:两个工作区如果都落到同一个家,光看调用者 cwd 分辨不出来
125
138
  // (两边 cwd 都在这个 root 里,校验会同时通过)。所以加载时先占位:
126
139
  // 谁先占谁是房主;后进来的认出来之后**不写也不崩** —— 她还能说话,只是不落盘。
127
140
  try {
128
- const claim = await claimRoot(root, { version: VERSION })
141
+ claiming = claimRoot(root, { version: VERSION })
142
+ const claim = await claiming
129
143
  tenancy.tenant = claim
130
144
  tenancy.herDir = herDir
145
+ if (stopped) return
131
146
  if (!claim.ok) {
132
147
  logger.error(`[dsh-fish] ${claim.why}`)
133
148
  } else if (claim.instanceId) {
134
149
  const id = claim.instanceId
135
150
  heartbeat = setInterval(() => {
136
- beatRoot(root, id)
151
+ beating = beatRoot(root, id)
137
152
  .then((still) => {
138
153
  if (still === false && tenancy.tenant.ok !== false) {
139
154
  // 续不上 = 家被别人接管了。从此不再写,也不再抢回来(否则两边来回覆盖)。
@@ -185,6 +200,7 @@ export function apply(ctx, baseConfig = {}) {
185
200
  // 首次启动播种:她最初的那一点点性格,是**第一个事实**,不是可调参数。
186
201
  // 之所以写成事件而不是配置项:配置项会被"打开设置页改一下",
187
202
  // 事件只能追加——想改就得由她自己 revise,这是 N4 的地基。
203
+ if (stopped) return
188
204
  const hasSeed = events.some((e) => e.type === 'seed')
189
205
  if (!hasSeed) {
190
206
  const seedText = String(
@@ -209,6 +225,7 @@ export function apply(ctx, baseConfig = {}) {
209
225
  }
210
226
 
211
227
  const model = deriveSelf(await readEvents(root).then((r) => r.events), { growthDays: config.growthDays })
228
+ if (stopped) return
212
229
 
213
230
  // ---- 她的屋子:工作区 + 专属会话(P3 核心)----
214
231
  // 这一步**先于**一切:她得先有个能被你看见的地方,才有"说话"这件事。
@@ -244,6 +261,7 @@ export function apply(ctx, baseConfig = {}) {
244
261
  } catch (err) {
245
262
  logger.warn('[dsh-fish] 身份文本预写失败', err?.message ?? err)
246
263
  }
264
+ if (stopped) return
247
265
  try {
248
266
  const off = registerIdentity(ctx, { root, herDir, log: (...a) => logger.info(...a) })
249
267
  if (typeof off === 'function') disposers.push(off)
@@ -272,6 +290,7 @@ export function apply(ctx, baseConfig = {}) {
272
290
  } catch (err) {
273
291
  home = { ok: false, why: String(err?.message ?? err).slice(0, 160), wsId: '', sessionId: '' }
274
292
  }
293
+ if (stopped) return
275
294
  // 观测台要能把「这个家在别处也开着」和普通失败分开报(2026-10-29 m02162)。
276
295
  tenancy.home = { ok: home.ok, blocked: home.blocked === true, sessionId: home.sessionId ?? '', why: home.why ?? '' }
277
296
  if (home.blocked === true) logger.warn(`[dsh-fish] ${home.why}`)
@@ -287,6 +306,7 @@ export function apply(ctx, baseConfig = {}) {
287
306
  }
288
307
  }
289
308
 
309
+ if (stopped) return
290
310
  // ---- 身份文本再落一次盘:屋子建好之后 activeCarry 等才齐,内容可能变 ----
291
311
  // 这条改动之后它**不再被宿主注入**(那是旧的 AGENTS.md 通道),只是落盘副本;
292
312
  // 真正进她上下文的是上面注册的 system prompt 段(每轮组装时现读文件)。
@@ -311,11 +331,14 @@ export function apply(ctx, baseConfig = {}) {
311
331
  logger.warn(`[dsh-fish] 口袋排序失败:${String(err?.message ?? err).slice(0, 120)}`)
312
332
  }
313
333
 
334
+ if (stopped) return
314
335
  // ---- 侧栏轮数:在侧栏亲口跟她说话也算一轮(issue #6,2026-10-28)----
315
336
  // 原来只有 /say 路由会 bumpTurns,用户直接打字时 rotateAfter 永不触发。
316
337
  // 观察者只读会话事件,认来源是"真人打的",一轮结束再结账。
317
338
  try {
318
- const off = registerTurns(ctx, { root, log: (...a) => logger.info(...a) })
339
+ // 2026-11-06(外部日志):被占用的实例也在写 `seen:`。轮数观察者现在是写盘方,
340
+ // 必须拿到活着的房主身份——占用权丢了(心跳续不上)会当场改成 ok:false。
341
+ const off = registerTurns(ctx, { root, tenant: tenancy.tenant, log: (...a) => logger.info(...a) })
319
342
  if (typeof off === 'function') disposers.push(off)
320
343
  } catch (err) {
321
344
  logger.warn('[dsh-fish] 轮数观察没挂上', err?.message ?? err)
@@ -324,50 +347,56 @@ export function apply(ctx, baseConfig = {}) {
324
347
  // 工具:一个写(growth_commit)、一个看(bili_look)。
325
348
  // 两者都必须成功注册;缺了任何一个她都会变残(只会说不会看,或只会记不会看)。
326
349
  if (ctx.tools?.register) {
327
- for (const reg of [
328
- () => registerCapture(ctx, { root, tenant: tenancy }),
329
- // 2026-11-01(issue #16 第二半,用户 m03454):这两个以前没拿到 tenancy,
330
- // 于是同机任何工作区加载这个插件都能替她写一条 looked:("她看过了")——
331
- // 看过就是永久判决,而按下判决的人不留名字。现在与 growth_commit 同一条闸。
332
- () => registerLook(ctx, { root, client: baseConfig.client, tenant: tenancy }),
333
- // 工具说明随说话开关走(2026-10-29):关着时身份文档里没有「在网上说话」那一节,
334
- // 说明里就不该再许诺"可以留评论"。启动时按当时读到的配置定一次,之后由 execute 同步。
335
- () => registerSpeak(ctx, {
336
- root,
337
- client: baseConfig.client,
338
- tenant: tenancy,
339
- speakEnabled: config?.comment?.enabled === true,
340
- }),
341
- () => registerRecall(ctx, { root }),
342
- // issue #7:日记不进事件流,所以她需要一个只读的窗口回头翻(her_diary)。
343
- () => registerHerDiary(ctx, { root }),
344
- () => registerWatch(ctx, { root, tenant: tenancy }),
345
- // R4:留话(不审批)。和 growth_commit 一样是纯追加,但收件人明确。
346
- () => registerLetter(ctx, { root, tenant: tenancy }),
347
- // issue #22 乙档(2026-11-04):醒来看的那张便条。写完会不会到期、
348
- // 到期那天摆不摆在最前面,都由 lib/note.js 与 scheduler 的唤醒正文负责,
349
- // 这个工具只做两件事:记一张、划掉一张。**常驻注册**,不像梦与主动说话那样要 .dev。
350
- () => registerNote(ctx, { root, tenant: tenancy }),
351
- // 0.6.17:纪念品。她只能「问」,真带回去要那个人在出门页点头。
352
- () => registerBringBack(ctx, { root, tenant: tenancy }),
353
- // 0.7.6(2026-10-30 m01274):请求事件。她有事想征得同意时弹一张卡,
354
- // 每天 1 次、每周 3 次;只有 time / rest 真会改东西(见 lib/ask.js)。
355
- () => registerAsk(ctx, { root, tenant: tenancy }),
356
- // 0.8.1 起(2026-10-30 m02837):梦 A 案——醒来记一句。0.9.0 起公开。
357
- () => registerDream(ctx, { root, tenant: tenancy }),
358
- // 0.8.1 起(2026-10-30 m04610):主动说话——她自己判断有没有话想说。0.9.0 起公开。
359
- // 2026-10-30 真机:这里原来只有 { root, log },guardWrite 拿到 undefined 的 state,
360
- // checkCaller(undefined) 每次都判「不在这个家」;写入守卫要的是 tenancy。
361
- () => registerProactive(ctx, { root, tenant: tenancy, log: (...a) => logger.info(...a) }),
362
- ]) {
363
- try {
364
- const d = reg()
365
- if (typeof d === 'function') disposers.push(d)
366
- else if (d && typeof d.dispose === 'function') disposers.push(() => d.dispose())
367
- } catch (err) {
368
- logger.warn('[dsh-fish] 工具注册失败', err?.message ?? err)
350
+ // 工具使用经验(2026-11-07,用户 m00882):注册期间把 register 换成
351
+ // 「包一层 execute 再交出去」的版本,用完立刻换回来。这样 12 个 registerXxx
352
+ // 一行都不用改,`defineTool` 的「定义原样交出去」约定也不动。
353
+ withUseDuringRegistration(ctx, root, () => {
354
+ for (const reg of [
355
+ () => registerCapture(ctx, { root, tenant: tenancy }),
356
+ // 2026-11-01(issue #16 第二半,用户 m03454):这两个以前没拿到 tenancy,
357
+ // 于是同机任何工作区加载这个插件都能替她写一条 looked:("她看过了")——
358
+ // 看过就是永久判决,而按下判决的人不留名字。现在与 growth_commit 同一条闸。
359
+ () => registerLook(ctx, { root, client: baseConfig.client, tenant: tenancy }),
360
+ // 工具说明随说话开关走(2026-10-29):关着时说明里不该许诺"可以留评论",
361
+ // 只说「这会儿还说不出去」(P3,2026-11-07:这一节本身不再消失)。
362
+ // 启动时按当时读到的配置定一次,之后由 execute 同步。
363
+ () => registerSpeak(ctx, {
364
+ root,
365
+ client: baseConfig.client,
366
+ tenant: tenancy,
367
+ speakEnabled: config?.comment?.enabled === true,
368
+ }),
369
+ () => registerRecall(ctx, { root }),
370
+ // issue #7:日记不进事件流,所以她需要一个只读的窗口回头翻(her_diary)。
371
+ () => registerHerDiary(ctx, { root }),
372
+ () => registerWatch(ctx, { root, tenant: tenancy }),
373
+ // R4:留话(不审批)。和 growth_commit 一样是纯追加,但收件人明确。
374
+ () => registerLetter(ctx, { root, tenant: tenancy }),
375
+ // issue #22 乙档(2026-11-04):醒来看的那张便条。写完会不会到期、
376
+ // 到期那天摆不摆在最前面,都由 lib/note.js 与 scheduler 的唤醒正文负责,
377
+ // 这个工具只做两件事:记一张、划掉一张。**常驻注册**,不像梦与主动说话那样要 .dev。
378
+ () => registerNote(ctx, { root, tenant: tenancy }),
379
+ // 0.6.17:纪念品。她只能「问」,真带回去要那个人在出门页点头。
380
+ () => registerBringBack(ctx, { root, tenant: tenancy }),
381
+ // 0.7.6(2026-10-30 m01274):请求事件。她有事想征得同意时弹一张卡,
382
+ // 每天 1 次、每周 3 次;只有 time / rest 真会改东西(见 lib/ask.js)。
383
+ () => registerAsk(ctx, { root, tenant: tenancy }),
384
+ // 0.8.1 起(2026-10-30 m02837):梦 A 案——醒来记一句。0.9.0 起公开。
385
+ () => registerDream(ctx, { root, tenant: tenancy }),
386
+ // 0.8.1 起(2026-10-30 m04610):主动说话——她自己判断有没有话想说。0.9.0 起公开。
387
+ // 2026-10-30 真机:这里原来只有 { root, log },guardWrite 拿到 undefined 的 state,
388
+ // checkCaller(undefined) 每次都判「不在这个家」;写入守卫要的是 tenancy。
389
+ () => registerProactive(ctx, { root, tenant: tenancy, log: (...a) => logger.info(...a) }),
390
+ ]) {
391
+ try {
392
+ const d = reg()
393
+ if (typeof d === 'function') disposers.push(d)
394
+ else if (d && typeof d.dispose === 'function') disposers.push(() => d.dispose())
395
+ } catch (err) {
396
+ logger.warn('[dsh-fish] 工具注册失败', err?.message ?? err)
397
+ }
369
398
  }
370
- }
399
+ })
371
400
  }
372
401
 
373
402
  // 观测台路由
@@ -394,23 +423,44 @@ export function apply(ctx, baseConfig = {}) {
394
423
  }
395
424
 
396
425
  // 宿主可能不 await apply,所以自己兜住异常:启动失败不能带走整个插件树
397
- boot().catch((err) => {
426
+ const booted = boot().catch((err) => {
398
427
  logger.error('[dsh-fish] 启动失败', err?.stack ?? err)
399
428
  console.error('[dsh-fish] 启动失败', err)
400
429
  })
401
430
 
431
+ // 卡片被别的插件接走之后(dsh-fish/card),人的回答从这里回来(lib/cardanswer.js)。
432
+ // 同步挂上:宿主重启后 IM 插件可能先连上、先收到人的回答,这时启动还没走完。等它走完才知道这个家是不是自己的。
433
+ // 没认领到就不写,和那些工具同一条理由:被占着,或者启动没走到认领那一步(默认值 ok 是真的,不能信)。
434
+ ctx.on?.('dsh-fish/answer', async (answer) => {
435
+ await booted
436
+ if (tenancy.tenant?.ok === false || !tenancy.tenant?.instanceId) {
437
+ return { ok: false, why: tenancy.tenant?.why || '启动没走完,不知道这个家是不是自己的,这次没写。' }
438
+ }
439
+ return answerCard(root, answer)
440
+ })
441
+
402
442
  function shutdown() {
443
+ if (stopped) return
444
+ stopped = true
403
445
  try { clearInterval(heartbeat) } catch { /* ignore */ }
404
- try {
405
- const id = tenancy.tenant?.instanceId
406
- if (id) releaseRoot(root, id).catch(() => {})
407
- } catch { /* ignore */ }
446
+ // 返回给 cordis:它等完 effect 才重新 apply,下一次认领一定在放开之后
447
+ const released = Promise.resolve(claiming)
448
+ .then(async (claim) => {
449
+ await beating
450
+ return claim?.ok && claim.instanceId ? releaseRoot(root, claim.instanceId) : false
451
+ })
452
+ .catch(() => {})
408
453
  try { scheduler?.dispose?.() } catch { /* ignore */ }
409
454
  try { clearWakeSection() } catch { /* ignore */ }
410
455
  for (const d of disposers) { try { d() } catch { /* ignore */ } }
456
+ // 磁盘卡住时别把卸载也挂住,依赖这个插件的服务要等它。用不 ref 的定时器,它不该拖住进程
457
+ return Promise.race([released, sleep(2000, undefined, { ref: false })])
411
458
  }
412
459
 
413
- ctx.on?.('dispose', shutdown)
460
+ // 宿主的 cordis 4 没有 'dispose' 事件,也不管 apply 返回的 { dispose },只认 ctx.effect。
461
+ // 只挂事件的话,插件停用后定时器还在跑,再启用时新旧两个调度器一起叫她。
462
+ if (typeof ctx.effect === 'function') ctx.effect(() => shutdown, 'dsh-fish.shutdown')
463
+ else ctx.on?.('dispose', shutdown)
414
464
 
415
465
  return {
416
466
  dispose() {
package/lib/keep.js CHANGED
@@ -20,8 +20,8 @@ import { askKeepsake, decideKeepsake } from './keepsake.js'
20
20
  import { askInChat, picked } from './askinchat.js'
21
21
 
22
22
  /** 点头的两个选项。文案用她那个人的语气,不用「批准/拒绝」。 */
23
- const YES = '准了'
24
- const NO = '算了'
23
+ export const YES = '准了'
24
+ export const NO = '算了'
25
25
 
26
26
  /**
27
27
  * 把「想带这个回去」问进会话里,等一个人选。
@@ -31,12 +31,20 @@ const NO = '算了'
31
31
  * 算了 → 这东西留在原地
32
32
  * 没答(超时/没人接)→ 还挂着,出门页里仍可以点头
33
33
  */
34
- async function askBack(ctx, root, { sessionId, item, signal }) {
34
+ /** 卡开着的时候别处(观测台、接卡的插件)先答了:回执照账上的说,不照这张卡上点的说。 */
35
+ function handledElsewhere(d) {
36
+ return { state: 'answered', note: d.item?.status === 'kept' ? '别处先答了,带回来了。' : '别处先答了,没带。', said: 'failed' }
37
+ }
38
+
39
+ async function askBack(ctx, root, { sessionId, item, signal, rawWhy }) {
35
40
  const why = String(item?.why ?? '').trim()
36
41
  const name = String(item?.rel ?? '').split(/[\\/]/).pop() || String(item?.rel ?? '')
37
42
  const r = await askInChat(ctx, {
38
43
  sessionId,
39
44
  signal,
45
+ root,
46
+ rawTexts: [rawWhy],
47
+ cardId: `keepsake:${item?.id ?? ''}`,
40
48
  questions: [
41
49
  {
42
50
  id: 'keepsake',
@@ -52,12 +60,14 @@ async function askBack(ctx, root, { sessionId, item, signal }) {
52
60
  const said = picked(r.answers, 'keepsake')
53
61
  if (said === YES) {
54
62
  const d = decideKeepsake(root, { id: item.id, yes: true })
63
+ if (d.handled) return handledElsewhere(d)
55
64
  return d.ok
56
65
  ? { state: 'answered', note: '准了,带回来了。', said: 'yes' }
57
- : { state: 'answered', note: '准了,没复制成。', said: 'yes' }
66
+ : { state: 'answered', note: `准了,${d.note}`, said: 'failed' }
58
67
  }
59
68
  if (said === NO) {
60
- decideKeepsake(root, { id: item.id, yes: false })
69
+ const d = decideKeepsake(root, { id: item.id, yes: false })
70
+ if (d.handled) return handledElsewhere(d)
61
71
  return { state: 'answered', note: '没带。', said: 'no' }
62
72
  }
63
73
  return { state: 'unanswered', note: '问了。等他点头。', said: '' }
@@ -101,9 +111,11 @@ export function registerBringBack(ctx, deps = {}) {
101
111
  ? `[没问:${out.note}]`
102
112
  : out.said === 'yes'
103
113
  ? '[准了]'
104
- : out.said === 'no'
105
- ? '[算了]'
106
- : '[已问]',
114
+ : out.said === 'failed'
115
+ ? `[${out.note}]`
116
+ : out.said === 'no'
117
+ ? '[算了]'
118
+ : '[已问]',
107
119
  },
108
120
  ],
109
121
  },
@@ -135,7 +147,7 @@ export function registerBringBack(ctx, deps = {}) {
135
147
 
136
148
  // 0.7.5:问她那个人。这一步会等到有人选或者超时(默认 5 分钟),
137
149
  // 但无论走哪条路,台账里都已经有一条 asked——不会因为没人答就丢掉这件事。
138
- const back = await askBack(ctx, root, { sessionId, item: r.item, signal: exec?.signal })
150
+ const back = await askBack(ctx, root, { sessionId, item: r.item, signal: exec?.signal, rawWhy: args?.why })
139
151
  return { ok: true, note: back.note, said: back.said }
140
152
  },
141
153
  }),
package/lib/keepsake.js CHANGED
@@ -115,7 +115,9 @@ export function askKeepsake(root, { outing = null, sessionId = '', rel = '', why
115
115
  if (mine.length >= KEEPSAKES_PER_TRIP) {
116
116
  return { ok: false, note: '这一趟带得够多了。' }
117
117
  }
118
- if (open.length >= 1) return { ok: false, note: '上一件还等着点头。' }
118
+ if (open.length >= 1) {
119
+ return { ok: false, note: open.some((it) => it.copyFailedAt) ? '上一件他点了头,只是没复制成,还没带回来。' : '上一件还等着点头。' }
120
+ }
119
121
 
120
122
  const cwd = String(outing.cwd ?? '')
121
123
  const target = resolve(cwd, raw)
@@ -156,7 +158,7 @@ export function decideKeepsake(root, { id = '', yes = false, now = Date.now() }
156
158
  const db = readDb(root)
157
159
  const item = db.items.find((it) => it && it.id === String(id))
158
160
  if (!item) return { ok: false, note: '没找到这一件。' }
159
- if (item.status !== 'asked') return { ok: false, note: '这一件已经处理过了。' }
161
+ if (item.status !== 'asked') return { ok: false, item, handled: true, note: '这一件已经处理过了。' }
160
162
 
161
163
  if (!yes) {
162
164
  item.status = 'declined'
@@ -187,23 +189,28 @@ export function decideKeepsake(root, { id = '', yes = false, now = Date.now() }
187
189
 
188
190
  const dk = dateKey(new Date(now))
189
191
  const dir = keepsakesDir(root, dk)
192
+ const dest = join(dir, `${item.id}-${basename(target)}`)
190
193
  try {
191
194
  mkdirSync(dir, { recursive: true })
192
- const dest = join(dir, `${item.id}-${basename(target)}`)
193
195
  copyFileSync(target, dest)
194
- item.status = 'kept'
195
- item.decidedAt = now
196
- item.keptAs = `${dk}/${basename(dest)}`
197
- item.size = st.size
198
- writeDb(root, db)
199
- return { ok: true, item, note: '带回来了。' }
200
196
  } catch (err) {
201
- item.status = 'declined'
202
- item.decidedAt = now
203
- item.why = `复制失败:${String(err?.message ?? err).slice(0, 120)}`
197
+ // 他说的是准了,只是这次没复制成(占用、权限)。还挂着,再答一次再试,不记成没带。
198
+ item.copyFailedAt = now
199
+ try { writeDb(root, db) } catch { /* 写不进去也还是 asked,只是下一件的说法不准 */ }
200
+ return { ok: false, item, note: `没复制成(${String(err?.code ?? '出错了')})。` }
201
+ }
202
+ item.status = 'kept'
203
+ item.decidedAt = now
204
+ item.keptAs = `${dk}/${basename(dest)}`
205
+ item.size = st.size
206
+ delete item.copyFailedAt
207
+ try {
204
208
  writeDb(root, db)
205
- return { ok: false, item, note: '没复制成。' }
209
+ } catch (err) {
210
+ // 盘上还是 asked:再答一次会覆盖同名文件,把账补上
211
+ return { ok: false, item, note: `文件到家了,账没记上(${String(err?.code ?? '出错了')}),再答一次。` }
206
212
  }
213
+ return { ok: true, item, note: '带回来了。' }
207
214
  }
208
215
 
209
216
  /** 还等着点头的那几件(界面用)。 */