dsh-later 1.0.0 → 1.1.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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * slash 命令的执行结果:**只表达「是否被受理」**。
3
+ *
4
+ * 为什么单独成模块并写清楚:`dsh-commands` 的 admission 语义只回
5
+ * `RemoteResult<{matched}>`,handler 的业务结果不从这里返回(它落在会话记录 /
6
+ * 投影里)。因此客户端**不得**用本类型判断「业务是否真的发生了」——例如删除
7
+ * 暂停项时 host 可能返回 `deleted:false`,而这里照样是 `matched: true`。
8
+ * 需要业务事实时以 `userSchedules` 投影为准(见 ScheduleDock 的观察窗对账)。
9
+ *
10
+ * 本模块被 host 与 client 两侧共用(与 smart-window / time-utils 同款:
11
+ * 同时列出在 tsconfig.json 与 tsconfig.client.json 的 include 中)。
12
+ *
13
+ * @module dsh-later/command-outcome
14
+ */
15
+ export {};
package/lib/commands.js CHANGED
@@ -1,4 +1,4 @@
1
- import { userScheduleCreate, userScheduleDelete, userScheduleEditPrompt, userScheduleList, resolvePromptLimits, } from './user-tools.js';
1
+ import { userScheduleCreate, userScheduleDelete, userScheduleEditPrompt, userScheduleList, userSchedulePause, userScheduleResume, resolvePromptLimits, } from './user-tools.js';
2
2
  import { DEFAULT_MAX_SCHEDULES } from './user-tools.js';
3
3
  import { epochFromLocal, localFieldsOf } from './smart-window.js';
4
4
  import { detectTimeZone, formatAbsolute, formatRelative } from './time-utils.js';
@@ -27,7 +27,7 @@ function parsePayload(rawInput) {
27
27
  /** 中文数字 → 数值(支持 十/两/半 由调用方特判)。 */
28
28
  const CN_DIGIT = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
29
29
  function cnToNumber(text) {
30
- if (/^\d+$/.test(text))
30
+ if (/^\d+(?:\.\d+)?$/.test(text))
31
31
  return Number(text);
32
32
  const tenIdx = text.indexOf('十');
33
33
  if (tenIdx >= 0) {
@@ -68,11 +68,11 @@ const CN_UNIT_SECONDS = {
68
68
  };
69
69
  /** 在归一化文本开头尝试匹配一个时间表达式;失败返回 undefined。 */
70
70
  function matchTimeExpr(s) {
71
- // 1. 拉丁相对时长:+30m / +1h30m / +90s / +1w(后面可跟内容)
72
- const latinRel = /^\+\s*((?:\d+\s*(?:s|m|h|d|w)\s*)+)(?![0-9a-z])/i.exec(s);
71
+ // 1. 拉丁相对时长:+30m / +1h30m / +90s / +1w / +2.5h(后面可跟内容)
72
+ const latinRel = /^\+\s*((?:\d+(?:\.\d+)?\s*(?:s|m|h|d|w)\s*)+)(?![0-9a-z])/i.exec(s);
73
73
  if (latinRel !== null) {
74
74
  let total = 0;
75
- const unitRe = /(\d+)\s*(s|m|h|d|w)/gi;
75
+ const unitRe = /(\d+(?:\.\d+)?)\s*(s|m|h|d|w)/gi;
76
76
  for (const match of (latinRel[1] ?? '').matchAll(unitRe)) {
77
77
  const n = Number(match[1]);
78
78
  const u = (match[2] ?? 'm').toLowerCase();
@@ -82,8 +82,8 @@ function matchTimeExpr(s) {
82
82
  return undefined;
83
83
  return { consumed: latinRel[0].length, target: { kind: 'after', afterSeconds: total } };
84
84
  }
85
- // 2. 中文相对时长:30分钟后 / 半小时后 / 两小时后 / 1天以后
86
- const cnRel = /^(半|\d+|[零一二两三四五六七八九十]+)\s*(个\s*小时|秒钟?|分钟?|小时|钟头|天|日|周)\s*(?:之)?后?/.exec(s);
85
+ // 2. 中文相对时长:30分钟后 / 半小时后 / 两小时后 / 1天以后 / 2.5小时后
86
+ const cnRel = /^(半|\d+(?:\.\d+)?|[零一二两三四五六七八九十]+)\s*(个\s*小时|秒钟?|分钟?|小时|钟头|天|日|周)\s*(?:之)?后?/.exec(s);
87
87
  if (cnRel !== null) {
88
88
  const value = cnRel[1] === '半' ? 0.5 : cnToNumber(cnRel[1] ?? '');
89
89
  const unitKey = (cnRel[2] ?? '').replace(/\s+/g, '');
@@ -419,6 +419,54 @@ export function userScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, o
419
419
  return { kind: 'success', text: JSON.stringify(result) };
420
420
  },
421
421
  };
422
+ /** 暂停 after 提醒:sidecar 留档 + 日志 delete(GUI ⏸)。 */
423
+ const pause = {
424
+ name: `${PREFIX}-pause`,
425
+ description: '暂停一条 after 类定时提醒(仅 GUI 内部通道;已到点不可暂停)。',
426
+ recordInput: false,
427
+ handler: async (invocation) => {
428
+ const parsed = parsePayload(invocation.rawInput);
429
+ if (!parsed.ok)
430
+ return renderError(parsed.text);
431
+ const id = parsed.value['id'];
432
+ const result = await userSchedulePause(id, invocation.agent, ctx);
433
+ if (!result.ok) {
434
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
435
+ }
436
+ try {
437
+ onUserChange?.(invocation.agent);
438
+ }
439
+ catch {
440
+ /* 通知失败不影响命令成功结果 */
441
+ }
442
+ return { kind: 'success', text: JSON.stringify(result) };
443
+ },
444
+ };
445
+ /** 恢复暂停项:after_seconds=remaining 重建;uid 不变、日志 id 会变。 */
446
+ const resume = {
447
+ name: `${PREFIX}-resume`,
448
+ description: '恢复一条已暂停的 after 提醒(仅 GUI 内部通道)。',
449
+ recordInput: false,
450
+ handler: async (invocation) => {
451
+ const parsed = parsePayload(invocation.rawInput);
452
+ if (!parsed.ok)
453
+ return renderError(parsed.text);
454
+ const uid = parsed.value['uid']
455
+ ?? parsed.value['id'];
456
+ const { maxSchedules } = resolveSettings();
457
+ const result = await userScheduleResume(uid, invocation.agent, ctx, maxSchedules);
458
+ if (!result.ok) {
459
+ return { kind: 'error', text: `${result.code}: ${result.message}` };
460
+ }
461
+ try {
462
+ onUserChange?.(invocation.agent);
463
+ }
464
+ catch {
465
+ /* 通知失败不影响命令成功结果 */
466
+ }
467
+ return { kind: 'success', text: JSON.stringify(result) };
468
+ },
469
+ };
422
470
  // 用户主动插话(GUI「插话发送」按钮):把已到点的 reminder 立即 steer 进 agent。
423
471
  // 仅 one-shot 支持;every 类提醒因 batch framing 需要更多 plumbing 暂未开放。
424
472
  const steer = {
@@ -511,7 +559,7 @@ export function userScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, o
511
559
  recordInput: false,
512
560
  handler: buildQuickHandler('user'),
513
561
  };
514
- return [create, list, del, edit, steer, quick, later];
562
+ return [create, list, del, edit, pause, resume, steer, quick, later];
515
563
  }
516
564
  /** 注册三个命令,返回统一 disposer。 */
517
565
  export function registerUserScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, onUserChange, onUserSteer) {
@@ -0,0 +1,66 @@
1
+ import { readOwnEvents } from './upstream-compat.js';
2
+ /**
3
+ * 投递确认的超时预算。
4
+ *
5
+ * 正常核对在**毫秒级**完成(消息 append 后同一次宏任务内即可读到);2s 是留给
6
+ * 极端调度延迟的宽裕上限,超时即判「未确认」——宁可让用户看到可重试的提示,
7
+ * 也不要静默当成成功。
8
+ */
9
+ export const DELIVERY_CONFIRM_TIMEOUT_MS = 2000;
10
+ /** 投递确认轮询间隔(远小于超时预算,保证正常情况一两轮即命中)。 */
11
+ const CONFIRM_POLL_INTERVAL_MS = 25;
12
+ /**
13
+ * 日志中是否出现该 message id。
14
+ *
15
+ * 判据是消息**真的进了会话日志**。这里刻意不进一步区分「进了 surface」与
16
+ * 「进了实际请求」:`agent.followup` 路径下二者由核心保证一致(消息成为独立一轮
17
+ * 的唯一条消息);而 `steer` 路径的丢失发生在**插入后同毫秒被 claim 移除**,
18
+ * 那种情况下连 `user/message` 都不会留下,本判据足以识别。
19
+ */
20
+ export function hasMessageInLog(session, messageId) {
21
+ try {
22
+ for (const event of readOwnEvents(session)) {
23
+ const data = event.data;
24
+ if (data === null || typeof data !== 'object')
25
+ continue;
26
+ const record = data;
27
+ // user/message 形态:{ content, source, role, id }
28
+ if (record.id === messageId)
29
+ return true;
30
+ // assistant/step 等包裹形态:{ message: { id, ... } }
31
+ const nested = record.message;
32
+ if (nested !== null && typeof nested === 'object' && nested.id === messageId) {
33
+ return true;
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ // 读日志失败一律按「未确认」处理:不确认就不敢写 dispatch。
39
+ return false;
40
+ }
41
+ return false;
42
+ }
43
+ /** 等待投递被日志确认;超时返回 false。 */
44
+ export async function confirmDelivery(session, messageId, timeoutMs = DELIVERY_CONFIRM_TIMEOUT_MS) {
45
+ if (hasMessageInLog(session, messageId))
46
+ return true;
47
+ const deadline = Date.now() + Math.max(0, timeoutMs);
48
+ while (Date.now() < deadline) {
49
+ await new Promise((resolve) => {
50
+ setTimeout(resolve, CONFIRM_POLL_INTERVAL_MS);
51
+ });
52
+ if (hasMessageInLog(session, messageId))
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ /**
58
+ * 取消息 id(`createUserMessage` 会生成 uuid)。
59
+ *
60
+ * `UserMessage.id` 在类型上是必填,但运行时若某代内核未填则这里回落空串,
61
+ * 调用方的确认逻辑对空 id 一律判「无法确认」。
62
+ */
63
+ export function messageIdOf(message) {
64
+ const id = message.id;
65
+ return typeof id === 'string' ? id : '';
66
+ }
package/lib/domain.js CHANGED
@@ -1,5 +1,11 @@
1
1
  /** 插件名(与 cordis 条目 id / manifest 一致)。 */
2
2
  export const name = 'dsh-later';
3
+ /**
4
+ * settings 命名空间。0.1.1 要求 branded `SettingsNamespace`、0.1.2 已删除
5
+ * `settingsNamespace()` helper——调用处以 `'dsh-later' as SettingsNamespace`
6
+ * 断言注册(见 index.ts),字面量只在这里维护一份。
7
+ */
8
+ export const SETTINGS_NAMESPACE = 'dsh-later';
3
9
  /** 会话投影键:GUI 通过 `useProjection` / `faceOf` 读取用户任务列表。 */
4
10
  export const PROJECTION_KEY = 'userSchedules';
5
11
  /**
package/lib/index.js CHANGED
@@ -4,34 +4,76 @@ import { userSchedulesProjectionUnit } from './projection.js';
4
4
  import { registerUserScheduleCommands } from './commands.js';
5
5
  import { UserScheduleRuntime } from './runtime.js';
6
6
  import { DEFAULT_MAX_PROMPT_CHARS, DEFAULT_MAX_SCHEDULES, resolvePromptLimits, userScheduleCreate, userScheduleDelete, userScheduleList, } from './user-tools.js';
7
- import { name } from './domain.js';
7
+ import { name, SETTINGS_NAMESPACE } from './domain.js';
8
8
  import { registerOwnedSessionEventType } from './owned-event-registration.js';
9
9
  /** 插件配置 schema(全可选,默认开箱即用;经 cordis config 覆盖)。 */
10
+ /**
11
+ * 插件配置 schema(**单一事实来源**)。
12
+ *
13
+ * 0.2.0 起 `ctx.settings.describe()` 直接读 `entry.fiber.runtime.Config`
14
+ * (见 dsh-settings `schema(entry)`:「`entry.fiber?.runtime?.Config`,且需有
15
+ * `toJSON`」),据此生成该插件设置页的表单。因此**可编辑字段必须全部声明在
16
+ * `Config` 上**——只放在 `SchedulerSettingsSchema` 里的字段在 0.2.0 下不会
17
+ * 出现在设置 UI 中。
18
+ *
19
+ * 历史:0.1.5 用 `settingsProvider.register(ns, schema, …)` 注册一个**独立于
20
+ * Config 的** schema(即 `SchedulerSettingsSchema`),0.1.7 删除了该 API。
21
+ * 两代并存期间两份 schema 内容一致,现统一为 `Config` 一份,`SchedulerSettingsSchema`
22
+ * 保留为别名以免大范围改动调用点。
23
+ */
10
24
  export const Config = s.object({
11
25
  /** 单 session 用户任务上限。 */
12
26
  maxSchedules: s.number().step(1).min(1).default(DEFAULT_MAX_SCHEDULES),
13
- });
14
- /** Settings namespace schema(设置 - 插件 配置 UI)。 */
15
- const SchedulerSettingsSchema = s.object({
16
- /** 单 session 用户任务上限。 */
17
- maxSchedules: s.number().step(1).min(1).default(DEFAULT_MAX_SCHEDULES),
18
27
  /** 是否解除提示字符的默认下限(1000);关闭时 maxPromptChars 不生效。 */
19
28
  allowLongPrompts: s.boolean().default(false),
20
29
  /** 提示字符上限(≥1 整数);仅在 allowLongPrompts=true 时生效。 */
21
30
  maxPromptChars: s.number().step(1).min(1).default(DEFAULT_MAX_PROMPT_CHARS),
22
- /** 智能时段:工作时间开始(HH:mm)。 */
23
- workStart: s.string().default('09:00'),
24
- /** 智能时段:工作时间结束(HH:mm)。 */
25
- workEnd: s.string().default('18:00'),
26
- /** 智能时段:午休开始(HH:mm)。 */
27
- lunchStart: s.string().default('12:00'),
28
- /** 智能时段:午休结束(HH:mm)。 */
29
- lunchEnd: s.string().default('14:00'),
30
- /** 智能时段:晚间结束(HH:mm)。 */
31
- eveningEnd: s.string().default('22:00'),
32
31
  /** 是否显示输入框右侧的定时按钮(仅 GUI 显隐;提醒触发与 dock 不受影响)。 */
33
32
  showButton: s.boolean().default(true),
34
- });
33
+ /**
34
+ * 插话投递方式(「插话发送」把提醒内容推给 agent 时进哪个队列)。
35
+ *
36
+ * - `followup`:入 `next-turn`,**独立成一轮**(等价于「正常发送」)。`next-turn`
37
+ * 与 `next-step` 是两条独立队列,核心的 `claim('next-step', …)` 不触碰它,
38
+ * 因此**不存在**与 `turn/start` 竞争而被吞的窗口。默认。
39
+ * - `steer`:入 `next-step`,在下一个 step 边界消费,更快;但若插话恰好撞上
40
+ * `turn/start`,核心的 `claim` 会把整批 `next-step` 取走并交给一个**请求上下文
41
+ * 已定**的 step,消息会落进 surface 却不进实际请求 —— 静默丢失(实测复现见
42
+ * runtime.steerById 注释)。
43
+ */
44
+ steerDelivery: s.union([s.const('followup'), s.const('steer')]).default('followup'),
45
+ /**
46
+ * 投递确认:投递后核对会话日志中是否出现该 message id。
47
+ *
48
+ * 关闭则恢复 fire-and-forget(不读日志、不保证送达)。
49
+ */
50
+ confirmDelivery: s.boolean().default(true),
51
+ /**
52
+ * 投递未确认时的处理。
53
+ *
54
+ * - `keep`:**不写 dispatch**,条目留在 active 可重试(配合确认默认开启,使
55
+ * `steer` 由「会丢」降级为「需重试」)。默认。
56
+ * - `discard`:按旧语义写 dispatch 出列 —— 注意这会让静默丢失重新变得可能,
57
+ * 仅供明确需要旧行为的场景。
58
+ */
59
+ onUnconfirmed: s.union([s.const('keep'), s.const('discard')]).default('keep'),
60
+ /**
61
+ * 时钟图标风格。
62
+ *
63
+ * - `auto`:按 DSH 内核版本自动选择(0.2.0+ 用新版官方描边风格,0.1.x 用旧版点阵风格)。默认。
64
+ * - `modern`:强制使用新版官方描边风格(24×24 stroke 圆头线帽)。
65
+ * - `classic`:强制使用旧版点阵风格(10×10 fill 方块 crispEdges)。
66
+ */
67
+ clockIconStyle: s.union([s.const('auto'), s.const('modern'), s.const('classic')]).default('auto'),
68
+ }).volatile();
69
+ /**
70
+ * 0.1.5 时代 settings namespace 注册所用的 schema。
71
+ *
72
+ * 与 {@link Config} 同源(同一 schema 对象):0.1.5 的
73
+ * `settingsProvider.register()` 需要显式传入,而 0.2.0 的 `describe()` 只认
74
+ * `Config`。保留此别名让两代共用一份定义,避免两份 schema 漂移。
75
+ */
76
+ const SchedulerSettingsSchema = Config;
35
77
  /** 所需服务(延后激活,缺失时插件不加载)。 */
36
78
  export const inject = ['agents', 'sessions', 'tools', 'commands', 'settings'];
37
79
  /** 解析配置(容错非法值回落默认)。 */
@@ -40,6 +82,14 @@ function resolveMaxSchedules(value) {
40
82
  return DEFAULT_MAX_SCHEDULES;
41
83
  return Math.floor(value);
42
84
  }
85
+ /**
86
+ * 本插件在 profile 里的**条目 id**。
87
+ *
88
+ * 必须与 `cordis.patch.yml` 的 `id:` 一致:0.2.0 的 `configForms.get(entryId)`
89
+ * 与 `SettingsForms.describe()` 的 `ns` 都用条目 id 寻址,用错会静默拿不到
90
+ * 表单/值(设置页空白,无报错)。
91
+ */
92
+ const ENTRY_ID = 'later';
43
93
  /** 根据 settings 计算当前生效的提示字符上限(与 user-tools.resolvePromptLimits 同源)。 */
44
94
  function resolveSchedulerPromptLimits(settings) {
45
95
  return resolvePromptLimits({
@@ -209,22 +259,92 @@ export function apply(ctx, config) {
209
259
  maxSchedules: startMaxSchedules,
210
260
  allowLongPrompts: false,
211
261
  maxPromptChars: DEFAULT_MAX_PROMPT_CHARS,
262
+ steerDelivery: 'followup',
263
+ confirmDelivery: true,
264
+ onUnconfirmed: 'keep',
265
+ clockIconStyle: 'auto',
212
266
  };
213
267
  let getSettings = () => startSettings;
214
268
  ctx.inject(['settings'], (settingsCtx) => {
215
- const scope = settingsCtx.settings.register(
269
+ // 0.1.7 移除了整个 `settings.yaml` 体系:`SettingsProvider.register()` 不复存在,
270
+ // `ctx.settings` 变成 `SettingsForms`(只有 configure/describe/prepareDocument),
271
+ // 命名空间 schema 改为**由 profile 声明驱动**(见 0.1.7 dsh-settings 的类型注释
272
+ // “Move the sections of the removed settings.yaml into the active profile”)。
273
+ //
274
+ // 照旧调用会抛错并让整个 settings 注入失败,因此这里做代次探测:拿不到
275
+ // `register` 就保持 `startSettings`(即 profile yml 的 `base` 值),
276
+ // 插件功能不受影响,只是不再支持运行时热改设置。
277
+ //
278
+ // TODO(0.1.7 配置迁移):把 SchedulerSettingsSchema 迁到 profile 声明式配置,
279
+ // 恢复 GUI 设置页与 live 生效。这是跨 15 个插件的架构课题,单独排期。
280
+ //
281
+ // 类型收窄:0.1.5 的 `SettingsProvider` 有 `register`,0.1.7 的 `SettingsForms`
282
+ // 没有——直接用联合类型会让两代都无法通过编译,故按「本插件所需的最小面」
283
+ // 声明本地接口,再对运行时对象做一次存在性判断。
284
+ const provider = settingsCtx.settings;
285
+ /**
286
+ * 把「settings 原始值」规整为运行时设置(容错非法值回落默认)。
287
+ * 三条代次轨道共用,避免各处重复解析逻辑。
288
+ */
289
+ const readSettings = (value) => ({
290
+ maxSchedules: resolveMaxSchedules(value?.maxSchedules ?? startSettings.maxSchedules),
291
+ allowLongPrompts: value?.allowLongPrompts === true,
292
+ maxPromptChars: typeof value?.maxPromptChars === 'number' && value.maxPromptChars >= 1
293
+ ? Math.floor(value.maxPromptChars)
294
+ : startSettings.maxPromptChars,
295
+ // 非法/缺失回落默认:投递方式默认 followup(安全侧),确认默认开,
296
+ // 未确认默认保留(不丢消息)。
297
+ steerDelivery: value?.steerDelivery === 'steer' ? 'steer' : 'followup',
298
+ confirmDelivery: value?.confirmDelivery !== false,
299
+ onUnconfirmed: value?.onUnconfirmed === 'discard' ? 'discard' : 'keep',
300
+ clockIconStyle: value?.clockIconStyle === 'modern' || value?.clockIconStyle === 'classic'
301
+ ? value.clockIconStyle
302
+ : 'auto',
303
+ });
304
+ // ── 轨道 1:0.2.0(settings rewrite 第二版)─────────────────────────────
305
+ //
306
+ // 0.2.0 的 `ctx.settings` 是 `SettingsForms`,**没有 `register()`**;表单由
307
+ // `describe()` 从 `entry.fiber.runtime.Config`(即本插件导出的 `Config`)
308
+ // 自动生成,值来自该 profile 条目已合并的 `config`。
309
+ //
310
+ // 因此这一代**无需注册**:字段已在 `Config` 上声明,宿主自会渲染设置页。
311
+ // 插件侧只需读取「当前生效配置」。但 `Config` 的默认值由 cordis 在构造时
312
+ // 填充,运行中热改需从 describe() 快照取——故这里订阅 document 更新事件,
313
+ // 把最新值同步进 getSettings,实现与 0.1.5 相同的 `applies: 'live'` 语义。
314
+ const forms = settingsCtx.settings;
315
+ if (typeof provider.register !== 'function' && typeof forms.describe === 'function') {
316
+ const describeValue = () => {
317
+ try {
318
+ // entry id = profile 条目 id(`cordis.patch.yml` 的 `id: later`),
319
+ // **不是** settings namespace('dsh-later')——见 dsh-settings
320
+ // describe():`ns: entry.options.id`。
321
+ const row = forms
322
+ .describe?.({ redactSecrets: true })
323
+ .find((candidate) => candidate.ns === ENTRY_ID);
324
+ return row?.value;
325
+ }
326
+ catch {
327
+ return undefined;
328
+ }
329
+ };
330
+ getSettings = () => readSettings(describeValue());
331
+ // 文档更新 → 重新取值(宿主在 describe 检测到变化时发该事件)。
332
+ settingsCtx.on('settings/document-updated', (ns) => {
333
+ if (ns !== ENTRY_ID && ns !== SETTINGS_NAMESPACE)
334
+ return;
335
+ getSettings = () => readSettings(describeValue());
336
+ });
337
+ return;
338
+ }
339
+ // ── 轨道 2:0.1.5 及更早(settings.yaml 体系)──────────────────────────
340
+ if (typeof provider.register !== 'function')
341
+ return;
342
+ const scope = provider.register(
216
343
  // 断言而非 settingsNamespace():0.1.1 要求 branded 类型,0.1.2 已删除该 helper。
217
- 'dsh-later', SchedulerSettingsSchema, { applies: 'live' });
344
+ SETTINGS_NAMESPACE, SchedulerSettingsSchema, { applies: 'live' });
218
345
  getSettings = () => {
219
346
  try {
220
- const value = scope.get();
221
- return {
222
- maxSchedules: resolveMaxSchedules(value?.maxSchedules ?? startSettings.maxSchedules),
223
- allowLongPrompts: value?.allowLongPrompts === true,
224
- maxPromptChars: typeof value?.maxPromptChars === 'number' && value.maxPromptChars >= 1
225
- ? Math.floor(value.maxPromptChars)
226
- : startSettings.maxPromptChars,
227
- };
347
+ return readSettings(scope.get());
228
348
  }
229
349
  catch {
230
350
  return startSettings;
@@ -254,10 +374,13 @@ export function apply(ctx, config) {
254
374
  ctx.effect(() => registerUserScheduleCommands(ctx, getSettings, notifyUserChange, notifyUserSteer), 'later.commands()');
255
375
  // 3. 每个 root agent:注册用户工具 + 用户调度器生命周期。
256
376
  ctx.effect(() => {
257
- const stopCreated = ctx.on('agent/created', ({ agent }) => {
377
+ // 0.1.7 起 `agent/created` 是**异步串行**事件:回调须返回 Promise,且首次
378
+ // 模型请求会等待初始化完成。0.1.5/0.1.2 的同步回调签名在 0.1.7 下会因
379
+ // 返回 void 而不匹配,故显式 async。
380
+ const stopCreated = ctx.on('agent/created', async ({ agent }) => {
258
381
  if (stopping || runtimes.has(agent) || !ctx.agents.roots().includes(agent))
259
382
  return;
260
- const runtime = new UserScheduleRuntime(ctx, agent);
383
+ const runtime = new UserScheduleRuntime(ctx, agent, getSettings);
261
384
  let cleanup = () => undefined;
262
385
  try {
263
386
  cleanup = agent.ctx.effect(() => {
@@ -17,7 +17,7 @@
17
17
  * - 容错读:JSON 损坏时把坏文件改名保留(.corrupt-<ts>),按空表继续;
18
18
  * - 进程内缓存:按 (mtimeMs, size) 校验,避免热路径反复读盘;
19
19
  * - 路径解析:`DSH_LATER_STATE_DIR`(测试/便携)> 旧名
20
- * `DSH_LATER_STATE_DIR`(升级过渡)> `$DSH_HOME` > `~/.dsh`
20
+ * `DSH_SESSION_SCHEDULER_STATE_DIR`(升级过渡)> `$DSH_HOME` > `~/.dsh`
21
21
  * (与 dsh-home-paths 的默认解析一致)。
22
22
  *
23
23
  * 注意:本模块只被 host 端引用;client bundle 不得引入(依赖 node:fs)。
@@ -26,10 +26,29 @@
26
26
  import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync, existsSync, unlinkSync, readdirSync, copyFileSync, } from 'node:fs';
27
27
  import { homedir } from 'node:os';
28
28
  import { join } from 'node:path';
29
+ /**
30
+ * 显式状态目录覆盖(测试 / 便携场景)。
31
+ *
32
+ * 两个变量名等价:`DSH_LATER_STATE_DIR` 是当前名,`DSH_SESSION_SCHEDULER_STATE_DIR`
33
+ * 是插件改名前(`session-scheduler`)的旧名,保留以兼容既有脚本与文档。
34
+ *
35
+ * 修复记录:此前两处都写成 `DSH_LATER_STATE_DIR ?? DSH_LATER_STATE_DIR`——
36
+ * 同一个变量自比较,旧名回退实际从未生效。旧名用户会静默落到 `$DSH_HOME`
37
+ * 默认目录,而 `legacyStateDir()` 又因"有 override"直接返回 undefined,
38
+ * 迁移也不会发生。
39
+ */
40
+ function stateDirOverride() {
41
+ for (const name of ['DSH_LATER_STATE_DIR', 'DSH_SESSION_SCHEDULER_STATE_DIR']) {
42
+ const value = process.env[name];
43
+ if (value !== undefined && value.trim().length > 0)
44
+ return value;
45
+ }
46
+ return undefined;
47
+ }
29
48
  /** 解析状态目录(优先级见模块注释)。 */
30
49
  function resolveStateDir() {
31
- const override = process.env['DSH_LATER_STATE_DIR'] ?? process.env['DSH_LATER_STATE_DIR'];
32
- if (override !== undefined && override.trim().length > 0)
50
+ const override = stateDirOverride();
51
+ if (override !== undefined)
33
52
  return override;
34
53
  const dshHomeEnv = process.env['DSH_HOME'];
35
54
  const home = dshHomeEnv !== undefined && dshHomeEnv.trim().length > 0 ? dshHomeEnv : join(homedir(), '.dsh');
@@ -40,8 +59,7 @@ function resolveStateDir() {
40
59
  * 不做迁移以免污染调用方给定的目录语义)。
41
60
  */
42
61
  function legacyStateDir() {
43
- const override = process.env['DSH_LATER_STATE_DIR'] ?? process.env['DSH_LATER_STATE_DIR'];
44
- if (override !== undefined && override.trim().length > 0)
62
+ if (stateDirOverride() !== undefined)
45
63
  return undefined;
46
64
  const dshHomeEnv = process.env['DSH_HOME'];
47
65
  const home = dshHomeEnv !== undefined && dshHomeEnv.trim().length > 0 ? dshHomeEnv : join(homedir(), '.dsh');
@@ -93,7 +111,7 @@ function fileOf(sessionId) {
93
111
  return join(stateDir(), `${safe}.json`);
94
112
  }
95
113
  function emptyFile() {
96
- return { version: 1, entries: {} };
114
+ return { version: 2, entries: {}, paused: {} };
97
115
  }
98
116
  /** 进程内缓存:sessionId → (mtime, size, file)。写路径同步更新缓存。 */
99
117
  const cache = new Map();
@@ -115,12 +133,18 @@ function readFresh(sessionId) {
115
133
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
116
134
  if (typeof parsed !== 'object' ||
117
135
  parsed === null ||
118
- parsed.version !== 1 ||
136
+ (parsed.version !== 1 &&
137
+ parsed.version !== 2) ||
119
138
  typeof parsed.entries !== 'object' ||
120
139
  parsed.entries === null) {
121
140
  throw new Error('unexpected shape');
122
141
  }
123
- const file = parsed;
142
+ // v1 → v2:补空 paused,写回时统一升到 2
143
+ const file = {
144
+ version: 2,
145
+ entries: parsed.entries,
146
+ paused: parsed.paused ?? {},
147
+ };
124
148
  cache.set(sessionId, { mtimeMs: stat.mtimeMs, size: stat.size, file });
125
149
  return file;
126
150
  }
@@ -187,14 +211,34 @@ function writeThrough(sessionId, file) {
187
211
  function safeName(sessionId) {
188
212
  return sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
189
213
  }
190
- /** 记录(新增或覆盖)一条所有权;幂等。 */
191
- export function recordOwnership(sessionId, id, delivery) {
192
- if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string' || id.length === 0)
193
- return;
214
+ /** 生成稳定插件 uid(pause/resume 跨日志 id 变更)。 */
215
+ export function newTaskUid() {
216
+ return globalThis.crypto?.randomUUID?.() ?? `uid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
217
+ }
218
+ /** 记录(新增或覆盖)一条所有权;幂等。仅显式传入 `uid` 时写入稳定身份。 */
219
+ export function recordOwnership(sessionId, id, delivery, uid, windowSeconds) {
220
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof id !== 'string' || id.length === 0) {
221
+ const bare = {
222
+ delivery,
223
+ createdAt: Date.now(),
224
+ ...(uid !== undefined ? { uid } : {}),
225
+ ...(typeof windowSeconds === 'number' ? { windowSeconds } : {}),
226
+ };
227
+ return bare;
228
+ }
194
229
  const file = readFresh(sessionId);
195
230
  const entries = { ...file.entries };
196
- entries[id] = { delivery, createdAt: Date.now() };
197
- writeThrough(sessionId, { version: 1, entries });
231
+ const nextUid = uid ?? entries[id]?.uid;
232
+ const nextWindow = windowSeconds ?? entries[id]?.windowSeconds;
233
+ const entry = {
234
+ delivery,
235
+ createdAt: Date.now(),
236
+ ...(nextUid !== undefined ? { uid: nextUid } : {}),
237
+ ...(typeof nextWindow === 'number' ? { windowSeconds: nextWindow } : {}),
238
+ };
239
+ entries[id] = entry;
240
+ writeThrough(sessionId, { version: 2, entries, paused: file.paused ?? {} });
241
+ return entry;
198
242
  }
199
243
  /** 撤销一条所有权;幂等(不存在时无操作)。 */
200
244
  export function removeOwnership(sessionId, id) {
@@ -205,7 +249,61 @@ export function removeOwnership(sessionId, id) {
205
249
  return;
206
250
  const entries = { ...file.entries };
207
251
  delete entries[id];
208
- writeThrough(sessionId, { version: 1, entries });
252
+ writeThrough(sessionId, { version: 2, entries, paused: file.paused ?? {} });
253
+ }
254
+ /** 按 uid 找当前仍 active 的 schedule id(缺失 undefined)。 */
255
+ export function findScheduleIdByUid(sessionId, uid) {
256
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof uid !== 'string')
257
+ return undefined;
258
+ for (const [id, entry] of Object.entries(readFresh(sessionId).entries)) {
259
+ if (entry.uid === uid)
260
+ return id;
261
+ }
262
+ return undefined;
263
+ }
264
+ /** 全部暂停留档(uid → entry)。 */
265
+ export function allPaused(sessionId) {
266
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
267
+ return {};
268
+ return readFresh(sessionId).paused ?? {};
269
+ }
270
+ /** 一条暂停留档。 */
271
+ export function getPaused(sessionId, uid) {
272
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof uid !== 'string')
273
+ return undefined;
274
+ return (readFresh(sessionId).paused ?? {})[uid];
275
+ }
276
+ /** 按暂停前 schedule id 找留档(pause 刚写完、投影 fold 见 delete 时用)。 */
277
+ export function findPausedByScheduleId(sessionId, scheduleId) {
278
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof scheduleId !== 'string')
279
+ return undefined;
280
+ for (const entry of Object.values(readFresh(sessionId).paused ?? {})) {
281
+ if (entry.lastScheduleId === scheduleId)
282
+ return entry;
283
+ }
284
+ return undefined;
285
+ }
286
+ /** 写入/覆盖一条暂停留档。 */
287
+ export function recordPaused(sessionId, entry) {
288
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof entry.uid !== 'string')
289
+ return;
290
+ const file = readFresh(sessionId);
291
+ writeThrough(sessionId, {
292
+ version: 2,
293
+ entries: file.entries,
294
+ paused: { ...(file.paused ?? {}), [entry.uid]: entry },
295
+ });
296
+ }
297
+ /** 移除一条暂停留档;幂等。 */
298
+ export function removePaused(sessionId, uid) {
299
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof uid !== 'string')
300
+ return;
301
+ const file = readFresh(sessionId);
302
+ const paused = { ...(file.paused ?? {}) };
303
+ if (!Object.prototype.hasOwnProperty.call(paused, uid))
304
+ return;
305
+ delete paused[uid];
306
+ writeThrough(sessionId, { version: 2, entries: file.entries, paused });
209
307
  }
210
308
  /** 测试辅助:清空进程内缓存(不删文件)。 */
211
309
  export function resetCacheForTests() {