pactive 0.0.2-alpha.1 → 0.0.3-alpha.1

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/README.md CHANGED
@@ -44,7 +44,8 @@ pactive dsh # dsh --profile pactive(只写 pactive
44
44
  `pi.skills` → `pi/skills/pactive`);`pi install pactive` 依赖该字段。
45
45
  - 宿主退出码 / 信号原样透传(信号 → 128+n)。
46
46
  - **stdout 纯净**:Pactive 自身的回执、诊断、进度一律走 stderr,
47
- stdout 只承载被启动宿主的输出,以及 `--print-plan` / `doctor --json` 的机器可读结果。
47
+ stdout 只承载被启动宿主的输出,以及显式请求的机器可读结果(`--print-plan`、
48
+ `doctor --json`、`config/tasks --json`、`--help`——`--help | head -8` 要可 grep)。
48
49
 
49
50
  ### 2.2 其它子命令
50
51
 
@@ -55,6 +56,11 @@ pactive dsh # dsh --profile pactive(只写 pactive
55
56
  | `pactive --print-plan <host>` | 打印注入计划 JSON(零写盘) | 0(未知宿主 2) |
56
57
  | `pactive doctor [--json]` | 宿主 / 版本 / 注入模式 / 状态根 / 权限 / 残留 | 0(有 FAIL 段则 1) |
57
58
  | `pactive install <host>` / `pactive uninstall <host>` | 幂等安装 / 卸载 | 0(有残留则 1) |
59
+ | `pactive config get [<key>] [--json]` | 只读:当前值与默认值(白名单 7 个 key) | 0(未知 key 2) |
60
+ | `pactive config set <key> <value> --confirm` | 写配置;**缺 `--confirm` 就零写盘** | 0(缺 confirm 2、未知 key 2、值非法 2) |
61
+ | `pactive schedule "<prompt>" --when <when>` | 登记定时任务(`10m` / `18:30` / ISO / `every 1h`) | 0(when 无法解析 2) |
62
+ | `pactive tasks [--json]` | 列出任务:id / 类型 / 下次到期 / 归属 / 正文 | 0 |
63
+ | `pactive cancel <taskId>` | 取消一条任务 | 0(未知 id 1) |
58
64
  | `pactive <未知宿主>` | 列出 pi / opcteam / dsh 到 stderr | 2 |
59
65
  | 宿主二进制缺失 | 提示安装 + `PACTIVE_<HOST>_BIN` 覆盖 | 127 |
60
66
 
package/bin/pactive.mjs CHANGED
@@ -18,13 +18,24 @@ const PKG_ROOT = path.resolve(CLI_DIR, '..');
18
18
  const FALLBACK_VERSION = '0.0.2-alpha.1';
19
19
  const STALE_LOCK_MS = 30 * 60 * 1000;
20
20
 
21
- function usage(io) {
22
- io.write(`pactive — proactive companion for Pi / OPCteam / DSH
21
+ function usage(out) {
22
+ out.write(`pactive — 主动助手:默认全部用自然语言操作,命令只是"可复制的一行"
23
23
 
24
- Usage:
24
+ 你可以直接对助手说:
25
+ 「我现在闲不闲?」 → 看空闲判定与 reason
26
+ 「10 分钟后提醒我喝水」 → 登记定时任务
27
+ 「主动提醒开启了吗?」 → 看开关状态
28
+ 「帮我开启主动提醒」 → 给出可复制命令(人工确认后才生效)
29
+
30
+ 命令(高级/运维):
25
31
  pactive <host> [args...] run a host with pactive injected (stdio inherited)
26
32
  pactive --print-plan <host> print the launch plan as JSON (no side effects)
27
33
  pactive doctor [--json] diagnose hosts, state root and permissions (read-only)
34
+ pactive config get [<key>] print current values and defaults (read-only)
35
+ pactive config set <key> <value> --confirm write one config key (human gate)
36
+ pactive schedule "<prompt>" --when <when> register a timed task (10m / 18:30 / ISO / every 1h)
37
+ pactive tasks [--json] list scheduled tasks (id / kind / due / owner)
38
+ pactive cancel <taskId> cancel one task
28
39
  pactive install <host> register pactive with a host (idempotent)
29
40
  pactive uninstall <host> unregister pactive and remove pactive state residue
30
41
  pactive --version | -v print the pactive version
@@ -222,6 +233,268 @@ async function doctorReport(env) {
222
233
  };
223
234
  }
224
235
 
236
+ // ─────────────────── 自然语言面向的子命令(契约 §2.1 / I57–I60) ───────────────────
237
+ //
238
+ // 这里是**唯一的人工闸门**:写配置必须显式 --confirm,缺了就 exit 2 且零写盘。
239
+ // stdout 只放 --json,人类可读文本一律走 stderr(沿用 I37)。
240
+
241
+ const CONFIRM_REFUSAL = 'pactive: refusing to write without --confirm (human gate)';
242
+ const HOST_KIND = 'pi'; // the state root is host-agnostic; 'pi' keeps doctor/CLI on one spelling.
243
+ function parseDurationMs(text) {
244
+ const m = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hour|hours|d|day|days)$/i.exec(String(text ?? '').trim());
245
+ if (!m) return null;
246
+ const unit = m[2].toLowerCase();
247
+ const factor = unit.startsWith('s') ? 1000 : unit.startsWith('m') ? 60000 : unit.startsWith('h') ? 3600000 : 86400000;
248
+ return Number(m[1]) * factor;
249
+ }
250
+
251
+ /** `10m` / `18:30` / ISO 8601 / `every 1h` → a spec that `core.schedule.add` accepts directly. */
252
+ function parseWhen(when, now = Date.now()) {
253
+ const text = String(when ?? '').trim();
254
+ if (text === '') return { kind: 'in', when: '10m', delayMs: 10 * 60000 };
255
+ const every = /^every\s+(.+)$/i.exec(text);
256
+ if (every) {
257
+ const everyMs = parseDurationMs(every[1]);
258
+ if (everyMs === null || everyMs <= 0) return null;
259
+ return { kind: 'every', when: text, everyMs };
260
+ }
261
+ const delayMs = parseDurationMs(text);
262
+ if (delayMs !== null) return { kind: 'in', when: text, delayMs };
263
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(text);
264
+ if (clock) {
265
+ const at = new Date(now);
266
+ at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
267
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1);
268
+ return { kind: 'at', when: text, at: at.getTime() };
269
+ }
270
+ const iso = Date.parse(text);
271
+ if (!Number.isNaN(iso)) return { kind: 'at', when: text, at: iso };
272
+ return null;
273
+ }
274
+
275
+ /** Pull `--name <value>` / `--name=<value>` out of args. */
276
+ function takeOption(args, name) {
277
+ const rest = [];
278
+ let value = null;
279
+ for (let i = 0; i < args.length; i += 1) {
280
+ const arg = args[i];
281
+ if (arg === name) {
282
+ value = args[i + 1] ?? null;
283
+ i += 1;
284
+ continue;
285
+ }
286
+ if (arg.startsWith(`${name}=`)) {
287
+ value = arg.slice(name.length + 1);
288
+ continue;
289
+ }
290
+ rest.push(arg);
291
+ }
292
+ return { value, args: rest };
293
+ }
294
+
295
+ /** JSON on stdout, human lines on stderr — never both for the same payload. */
296
+ function emit(io, asJson, payload, humanLines) {
297
+ if (asJson) io.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
298
+ else for (const line of humanLines) io.stderr.write(`${line}\n`);
299
+ return 0;
300
+ }
301
+
302
+ function configKeys(core) {
303
+ const described = core.config.describe();
304
+ return { described, keys: Object.keys(described.defaults) };
305
+ }
306
+
307
+ function coerceValue(spec, raw) {
308
+ const text = String(raw).trim();
309
+ if (spec.type === 'boolean') {
310
+ if (text === 'true') return { ok: true, value: true };
311
+ if (text === 'false') return { ok: true, value: false };
312
+ return { ok: false, reason: 'expected true|false' };
313
+ }
314
+ if (spec.type === 'number') {
315
+ const n = Number(text);
316
+ if (text === '' || !Number.isFinite(n)) return { ok: false, reason: 'expected a number' };
317
+ if (typeof spec.min === 'number' && n < spec.min) return { ok: false, reason: `expected >= ${spec.min}` };
318
+ return { ok: true, value: n };
319
+ }
320
+ if (spec.type === 'string|null') {
321
+ if (text === '' || text.toLowerCase() === 'null' || text.toLowerCase() === 'none') return { ok: true, value: null };
322
+ return { ok: true, value: text };
323
+ }
324
+ return { ok: false, reason: `unsupported type ${spec.type}` };
325
+ }
326
+
327
+ async function configGetCmd(args, env, io) {
328
+ const asJson = args.includes('--json');
329
+ const keys = args.filter((a) => a !== '--json');
330
+ const core = await loadCore();
331
+ const { described, keys: known } = configKeys(core);
332
+ if (keys.length > 1) {
333
+ io.stderr.write('pactive: usage: pactive config get [<key>] [--json]\n');
334
+ return 2;
335
+ }
336
+ const key = keys[0];
337
+ if (key !== undefined && !known.includes(key)) {
338
+ io.stderr.write(`pactive: unknown config key '${key}'\npactive: known keys: ${known.join(', ')}\n`);
339
+ return 2;
340
+ }
341
+ const state = core.loadState(core.resolvePaths(HOST_KIND, env));
342
+ const targets = key === undefined ? known : [key];
343
+ const values = targets.map((k) => ({
344
+ key: k,
345
+ value: core.config.get(state, k),
346
+ default: described.defaults[k],
347
+ humanGated: described.schema[k].humanGated === true,
348
+ }));
349
+ const payload = key === undefined ? { values } : values[0];
350
+ // Single key → the bare value on its own line (so `config get idle.enabled | grep -qx false` works),
351
+ // then the default; the full key list keeps one "key = value" line each.
352
+ const human = key === undefined
353
+ ? values.map(
354
+ (v) => `${v.key} = ${JSON.stringify(v.value)}${JSON.stringify(v.value) === JSON.stringify(v.default) ? ' (default)' : ''}`,
355
+ )
356
+ : [`${JSON.stringify(values[0].value)}`, `${key} (default: ${JSON.stringify(values[0].default)})`];
357
+ return emit(io, asJson, payload, human);
358
+ }
359
+
360
+ async function configSetCmd(args, env, io) {
361
+ const asJson = args.includes('--json');
362
+ const confirm = args.includes('--confirm');
363
+ const rest = args.filter((a) => a !== '--json' && a !== '--confirm');
364
+ const [key, raw] = rest;
365
+ const core = await loadCore();
366
+ const { described, keys: known } = configKeys(core);
367
+
368
+ // Validity first (a usage error must not read as "gate refused"), then the gate.
369
+ if (key === undefined || raw === undefined || rest.length > 2) {
370
+ io.stderr.write('pactive: usage: pactive config set <key> <value> --confirm\n');
371
+ return 2;
372
+ }
373
+ if (!known.includes(key)) {
374
+ io.stderr.write(`pactive: unknown config key '${key}'\npactive: known keys: ${known.join(', ')}\n`);
375
+ return 2;
376
+ }
377
+ if (!confirm) {
378
+ io.stderr.write(`${CONFIRM_REFUSAL}\n`);
379
+ io.stderr.write(`pactive: re-run with --confirm: pactive config set ${key} ${raw} --confirm\n`);
380
+ return 2;
381
+ }
382
+ const coerced = coerceValue(described.schema[key], raw);
383
+ if (!coerced.ok) {
384
+ io.stderr.write(`pactive: invalid value for ${key}: ${coerced.reason}\n`);
385
+ return 2;
386
+ }
387
+
388
+ const paths = core.resolvePaths(HOST_KIND, env);
389
+ let applied = null;
390
+ await core.mutateState(paths, (state) => {
391
+ applied = core.config.setHumanGated(state, key, coerced.value, { confirm: true });
392
+ if (!applied.ok) throw new PactiveError(`config refused: ${applied.reason}`, { code: 'CONFIG_REFUSED', exitCode: 1 });
393
+ return state;
394
+ });
395
+ return emit(
396
+ io,
397
+ asJson,
398
+ { ok: true, key, value: coerced.value, stateFile: paths.stateFile },
399
+ [`${key} = ${JSON.stringify(coerced.value)}`, `pactive: wrote ${paths.stateFile}`],
400
+ );
401
+ }
402
+
403
+ async function scheduleCmd(args, env, io) {
404
+ const asJson = args.includes('--json');
405
+ const confirm = args.includes('--confirm');
406
+ const taken = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--when');
407
+ const prompt = taken.args.join(' ').trim();
408
+ if (prompt === '') {
409
+ io.stderr.write('pactive: usage: pactive schedule "<prompt>" --when <when> [--confirm]\n');
410
+ return 2;
411
+ }
412
+ const now = Date.now();
413
+ const spec = parseWhen(taken.value ?? '10m', now);
414
+ if (!spec) {
415
+ io.stderr.write(`pactive: cannot parse --when '${taken.value}' (try 10m / 18:30 / ISO 8601 / every 1h)\n`);
416
+ return 2;
417
+ }
418
+ const core = await loadCore();
419
+ const paths = core.resolvePaths(HOST_KIND, env);
420
+ const next = await core.mutateState(paths, (state) => {
421
+ core.schedule.add(state, { ...spec, prompt, content: prompt, owner: state.identity?.agentId }, now);
422
+ return state;
423
+ });
424
+ const list = core.schedule.list(next);
425
+ const created = list[list.length - 1] ?? null;
426
+ return emit(
427
+ io,
428
+ asJson,
429
+ {
430
+ ok: true,
431
+ confirmed: confirm,
432
+ taskId: created?.id ?? null,
433
+ dueAt: created?.dueAt ?? null,
434
+ kind: spec.kind,
435
+ when: spec.when,
436
+ tasks: list.length,
437
+ },
438
+ [
439
+ `pactive: registered ${created?.id ?? '?'} (${spec.when} → ${new Date(created?.dueAt ?? now).toISOString()})`,
440
+ `prompt: ${prompt}`,
441
+ ],
442
+ );
443
+ }
444
+
445
+ async function tasksCmd(args, env, io) {
446
+ const asJson = args.includes('--json');
447
+ const core = await loadCore();
448
+ const state = core.loadState(core.resolvePaths(HOST_KIND, env));
449
+ const list = core.schedule.list(state);
450
+ const rows = list.map((t) => ({
451
+ id: t.id,
452
+ kind: t.kind,
453
+ dueAt: t.dueAt,
454
+ due: new Date(t.dueAt).toISOString(),
455
+ owner: t.owner ?? '',
456
+ active: t.active !== false,
457
+ prompt: t.prompt ?? '',
458
+ }));
459
+ return emit(
460
+ io,
461
+ asJson,
462
+ { count: rows.length, tasks: rows },
463
+ rows.length === 0
464
+ ? ['pactive: no scheduled tasks']
465
+ : rows.map(
466
+ (r) => `[${r.id}] ${r.kind} ${r.due} owner=${r.owner || '-'} ${r.active ? 'active' : 'inactive'}${r.prompt ? ` — ${r.prompt}` : ''}`,
467
+ ),
468
+ );
469
+ }
470
+
471
+ async function cancelCmd(args, env, io) {
472
+ const asJson = args.includes('--json');
473
+ const confirm = args.includes('--confirm');
474
+ const id = args.filter((a) => a !== '--json' && a !== '--confirm')[0];
475
+ if (id === undefined) {
476
+ io.stderr.write('pactive: usage: pactive cancel <taskId> [--confirm]\n');
477
+ return 2;
478
+ }
479
+ const core = await loadCore();
480
+ const paths = core.resolvePaths(HOST_KIND, env);
481
+ const before = core.schedule.list(core.loadState(paths));
482
+ if (!before.some((t) => t.id === id)) {
483
+ io.stderr.write(`pactive: unknown task id '${id}'\n`);
484
+ return 1;
485
+ }
486
+ const next = await core.mutateState(paths, (state) => {
487
+ core.schedule.cancel(state, id);
488
+ return state;
489
+ });
490
+ return emit(
491
+ io,
492
+ asJson,
493
+ { ok: true, confirmed: confirm, cancelled: id, tasks: core.schedule.list(next).length },
494
+ [`pactive: cancelled ${id} (${core.schedule.list(next).length} left)`],
495
+ );
496
+ }
497
+
225
498
  function printPlan(args, env, io) {
226
499
  const host = args[0];
227
500
  if (!host) {
@@ -396,12 +669,20 @@ export async function main(argv, env = process.env, io = { stdout: process.stdou
396
669
  return 0;
397
670
  case '--help':
398
671
  case '-h':
399
- usage(io.stderr);
672
+ // Explicit help goes to stdout: it is the requested output, not host noise
673
+ // (I37 protects the stdout of a *spawned host*; `--help | head -8` must be greppable).
674
+ usage(io.stdout);
400
675
  return 0;
401
676
  case '--print-plan':
402
677
  return printPlan(rest, env, io);
403
678
  case 'doctor':
404
679
  return doctorCmd(rest, env, io);
680
+ case 'config':
681
+ return rest[0] === 'set' ? configSetCmd(rest.slice(1), env, io) : configGetCmd(rest[0] === 'get' ? rest.slice(1) : rest, env, io);
682
+ case 'schedule':
683
+ case 'tasks':
684
+ case 'cancel':
685
+ return { schedule: scheduleCmd, tasks: tasksCmd, cancel: cancelCmd }[cmd](rest, env, io);
405
686
  case 'install':
406
687
  return installCmd(rest, env, io, 'install');
407
688
  case 'uninstall':
@@ -22,6 +22,7 @@ mcp__plugin_pactive_pactive__<tool>
22
22
  - `mcp__plugin_pactive_pactive__pactive_schedule`
23
23
  - `mcp__plugin_pactive_pactive__pactive_tell`
24
24
  - `mcp__plugin_pactive_pactive__pactive_history`
25
+ - `mcp__plugin_pactive_pactive__pactive_config`(只读;要改配置时只返回可复制命令)
25
26
 
26
27
  裸名(`pactive_status`)不一定能被 ToolSearch 发现;若命名空间工具没加载,用
27
28
  `select:mcp__plugin_pactive_pactive__pactive_status` 搜一次。
@@ -36,11 +37,13 @@ mcp__plugin_pactive_pactive__<tool>
36
37
  | 每小时 | `pactive_schedule` `when:"every 1h"` |
37
38
  | 告诉另一个会话 | `pactive_tell` `to` `body`(可选 `kind:"ask"`) |
38
39
  | 最近发生过什么 | `pactive_history` `limit` |
40
+ | 开关 / 阈值 / 安静时间是多少 | `pactive_config` `key`(只读,报当前值与默认值) |
41
+ | 帮我开启主动提醒 / 改阈值 | `pactive_config` `key` `value` → 只拿到 `pactive config set … --confirm`,交给用户执行 |
39
42
 
40
43
  ## 硬规则
41
44
 
42
45
  1. **不要替用户改配置**:pactive 的开关与配置是人工闸门(CLI `--confirm`)。用户要求改配置时,
43
- 告诉他用 `pactive config set … --confirm`,不要自行绕过。
46
+ 告诉他用 `pactive config set … --confirm`,不要自行绕过;用户执行后下一轮用 `pactive_status` 复核。
44
47
  2. **回执如实转述**:`queued` 就是**还没送到**(对端离线),不要说成已送达。
45
48
  3. **默认不打扰**:空闲触发默认关闭;用户没明确要「空闲自动推进」时,不要假设它已开启。
46
49
  4. **只报新事实**:状态类输出要给出 `idle.reason` 与护栏结论,不要泛泛说「一切正常」。
package/dsh/index.js CHANGED
@@ -66,6 +66,14 @@ async function markControlReturned(ctx) {
66
66
  }
67
67
 
68
68
  const STATUS_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
69
+ const CONFIG_PARAMETERS = {
70
+ type: "object",
71
+ properties: {
72
+ key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.*)" },
73
+ value: { type: "string", description: "想改成的新值;给了就只返回 `pactive config set <key> <value> --confirm`,不落盘" },
74
+ },
75
+ additionalProperties: false,
76
+ };
69
77
  const SCHEDULE_PARAMETERS = {
70
78
  type: "object",
71
79
  properties: {
@@ -89,7 +97,8 @@ export function apply(ctx, config) {
89
97
  ctx.tools.register({
90
98
  name: "pactive_status",
91
99
  description:
92
- "Pactive 主动机制状态:空闲判定与 reason、护栏四闸门、定时任务数、最近活动。只读。",
100
+ "当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」时用。" +
101
+ "→ 读当前空闲判定、reason、护栏四闸门、任务数与最近活动。只读,不落盘。",
93
102
  parameters: STATUS_PARAMETERS,
94
103
  async execute(args = {}) {
95
104
  try {
@@ -121,7 +130,10 @@ export function apply(ctx, config) {
121
130
 
122
131
  ctx.tools.register({
123
132
  name: "pactive_schedule",
124
- description: "登记一个定时任务(真正落盘)。when 支持时长(10m)、时刻(18:30)、ISO 时间、every 1h。",
133
+ description:
134
+ "当用户说「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」时用。" +
135
+ "→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h。" +
136
+ "会写盘,需要人工确认(只在用户明确要求时调用)。",
125
137
  parameters: SCHEDULE_PARAMETERS,
126
138
  async execute(args = {}) {
127
139
  try {
@@ -155,6 +167,46 @@ export function apply(ctx, config) {
155
167
  }
156
168
  },
157
169
  });
170
+
171
+ // Read-only by construction: a write request returns the command, never a write (contract §2.3).
172
+ ctx.tools.register({
173
+ name: "pactive_config",
174
+ description:
175
+ "当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」时用。" +
176
+ "→ 读配置值与默认值;要改配置时只返回一条需要人工确认的可复制命令。只读,不落盘。",
177
+ parameters: CONFIG_PARAMETERS,
178
+ async execute(args = {}) {
179
+ try {
180
+ const core = await loadCore();
181
+ const described = core.config?.describe ? core.config.describe() : { schema: {}, defaults: {} };
182
+ const known = Object.keys(described.defaults);
183
+ const key = typeof args.key === "string" ? args.key.trim() : "";
184
+ const rawValue = args.value === undefined || args.value === null ? "" : String(args.value).trim();
185
+ if (key !== "" && !known.includes(key)) {
186
+ return { ok: false, error: `未知配置键(${key});已知:${known.join(", ")}` };
187
+ }
188
+ const paths = core.resolvePaths(HOST_KIND, process.env);
189
+ const state = (await readState(core, paths)) ?? core.defaultState();
190
+ const values = (key === "" ? known : [key]).map((k) => ({
191
+ key: k,
192
+ value: core.config?.get ? core.config.get(state, k) : null,
193
+ default: described.defaults[k] ?? null,
194
+ humanGated: described.schema?.[k]?.humanGated === true,
195
+ }));
196
+ if (rawValue === "") return { ok: true, readOnly: true, wrote: false, values };
197
+ return {
198
+ ok: false,
199
+ wrote: false,
200
+ reason: "need_human_confirmation",
201
+ command: `pactive config set ${key} ${rawValue} --confirm`,
202
+ hint: "不要自己改状态文件;把 command 原样给用户,等他执行后下一轮用 pactive_status 复核。",
203
+ values,
204
+ };
205
+ } catch (err) {
206
+ return { ok: false, error: errorText(err) };
207
+ }
208
+ },
209
+ });
158
210
  };
159
211
 
160
212
  const registerCommands = () => {
@@ -35,13 +35,29 @@ export const TOOL_DEFINITIONS = [
35
35
  {
36
36
  name: "pactive_status",
37
37
  description:
38
- "Pactive 主动机制状态:空闲判定与 reason、护栏四闸门、定时任务数、最近活动。只读。",
38
+ "当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」时用。" +
39
+ "→ 读当前空闲判定、reason、护栏四闸门、任务数与最近活动。只读,不落盘。",
39
40
  inputSchema: obj({}, []),
40
41
  },
42
+ {
43
+ name: "pactive_config",
44
+ description:
45
+ "当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」时用。" +
46
+ "→ 读配置值与默认值;要改配置时只返回一条需要人工确认的可复制命令。只读,不落盘。",
47
+ inputSchema: obj(
48
+ {
49
+ key: str("配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.*)"),
50
+ value: str("想改成的新值;给了就只返回 `pactive config set <key> <value> --confirm`,不落盘"),
51
+ },
52
+ [],
53
+ ),
54
+ },
41
55
  {
42
56
  name: "pactive_schedule",
43
57
  description:
44
- "登记一个定时任务(真正落盘)。when 支持时长(10m)、时刻(18:30)、ISO 时间、every 1h。",
58
+ "当用户说「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」时用。" +
59
+ "→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h。" +
60
+ "会写盘,需要人工确认(只在用户明确要求时调用)。",
45
61
  inputSchema: obj(
46
62
  {
47
63
  prompt: str("任务正文(到点注入给会话的内容)"),
@@ -53,7 +69,9 @@ export const TOOL_DEFINITIONS = [
53
69
  {
54
70
  name: "pactive_tell",
55
71
  description:
56
- "给同机另一个会话投递一条消息。对端离线时进入 outbox 排队,不伪装成功。",
72
+ "当用户说「告诉另一个会话我有空」「问一下隔壁 agent 跑完了吗」时用。" +
73
+ "→ 给同机另一个会话投递消息;对端离线时进入 outbox 排队,不伪装成功。" +
74
+ "会写盘,需要人工确认(只在用户明确要求时调用)。",
57
75
  inputSchema: obj(
58
76
  {
59
77
  to: str("目标会话的 agentId 或名字"),
@@ -65,7 +83,9 @@ export const TOOL_DEFINITIONS = [
65
83
  },
66
84
  {
67
85
  name: "pactive_history",
68
- description: "读取 Pactive 事件账本尾部(触发→投递→回应→结账),用于追溯主动行为。",
86
+ description:
87
+ "当用户问「刚才那几次主动提醒都发生了什么」「有没有真的触发过」时用。" +
88
+ "→ 读事件账本尾部(触发→投递→回应→结账)。只读。",
69
89
  inputSchema: obj({ limit: { type: "integer", description: "返回条数,默认 20" } }, []),
70
90
  },
71
91
  ];
@@ -117,6 +137,37 @@ export async function runTool(name, args = {}, options = {}) {
117
137
  });
118
138
  }
119
139
 
140
+ case "pactive_config": {
141
+ // Read-only. A write request never lands: the model gets a copy-pasteable command
142
+ // whose --confirm only a human can type (contract §2.3 / I59).
143
+ const described = core.config?.describe ? core.config.describe() : { schema: {}, defaults: {}, humanGatedKeys: [] };
144
+ const known = Object.keys(described.defaults);
145
+ const key = typeof args.key === "string" ? args.key.trim() : "";
146
+ const rawValue = args.value === undefined || args.value === null ? "" : String(args.value).trim();
147
+ if (key !== "" && !known.includes(key)) {
148
+ return json(`ERROR INVALID_ARGUMENT: 未知配置键(${key});已知:${known.join(", ")}`, true);
149
+ }
150
+ const state = (await readState(core, paths)) ?? core.defaultState();
151
+ const values = (key === "" ? known : [key]).map((k) => ({
152
+ key: k,
153
+ value: core.config?.get ? core.config.get(state, k) : null,
154
+ default: described.defaults[k] ?? null,
155
+ humanGated: described.schema?.[k]?.humanGated === true,
156
+ }));
157
+ if (rawValue === "") {
158
+ return json({ ok: true, readOnly: true, wrote: false, values, stateFile: paths.stateFile });
159
+ }
160
+ return json({
161
+ ok: false,
162
+ readOnly: false,
163
+ wrote: false,
164
+ reason: "need_human_confirmation",
165
+ command: `pactive config set ${key} ${rawValue} --confirm`,
166
+ hint: "不要自己改状态文件;把 command 原样给用户,等他执行后下一轮用 pactive_status 复核。",
167
+ values,
168
+ });
169
+ }
170
+
120
171
  case "pactive_schedule": {
121
172
  const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
122
173
  if (prompt === "") return json("ERROR INVALID_ARGUMENT: prompt 必填", true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pactive",
3
- "version": "0.0.2-alpha.1",
3
+ "version": "0.0.3-alpha.1",
4
4
  "description": "Pactive:跨 Pi / OPCteam / DSH 的主动能力运行时(idle 判定默认关闭、人工闸门、事件账本)。专有软件:使用条款见包内 LICENSE(发行物含 Pi 扩展源码 pi/extension.ts)。",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
package/pi/extension.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Pactive — Pi extension (the single shipped entry (Pi loads this .ts directly via jiti; there is no dist copy)).
3
3
  *
4
4
  * Surface:
5
- * - tools: `pactive_status`, `pactive_schedule`
5
+ * - tools: `pactive_status`, `pactive_schedule`, `pactive_config` (read-only: proposes a
6
+ * `pactive config set … --confirm` command instead of writing — contract §2.3 / I59)
6
7
  * - command: `/pactive status|tasks|schedule <when> <prompt>|help`
7
8
  * - events: session_start / input / agent_start / tool_execution_start /
8
9
  * message_end / agent_settled / session_shutdown → activity + control tracking
@@ -203,6 +204,14 @@ const SCHEDULE_PARAMETERS = {
203
204
  required: ["prompt"],
204
205
  additionalProperties: false,
205
206
  };
207
+ const CONFIG_PARAMETERS = {
208
+ type: "object",
209
+ properties: {
210
+ key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.*)" },
211
+ value: { type: "string", description: "想改成的新值;给了就只返回 `pactive config set <key> <value> --confirm`,不落盘" },
212
+ },
213
+ additionalProperties: false,
214
+ };
206
215
 
207
216
  /** Mark an activity kind; never throws. */
208
217
  async function markActivity(kind: "user" | "agent" | "session-start" | "session-end" | "tool"): Promise<void> {
@@ -237,7 +246,8 @@ export default function activate(pi: PactiveExtensionAPI): void {
237
246
  name: "pactive_status",
238
247
  label: "Pactive Status",
239
248
  description:
240
- "Pactive 主动机制状态:空闲判定与 reason、护栏四闸门、定时任务数、最近活动。只读。",
249
+ "当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」时用。" +
250
+ "→ 读当前空闲判定、reason、护栏四闸门、任务数与最近活动。只读,不落盘。",
241
251
  parameters: STATUS_PARAMETERS,
242
252
  async execute() {
243
253
  try {
@@ -276,7 +286,10 @@ export default function activate(pi: PactiveExtensionAPI): void {
276
286
  pi.registerTool({
277
287
  name: "pactive_schedule",
278
288
  label: "Pactive Schedule",
279
- description: "登记一个定时任务(真正落盘)。when 支持时长(10m)、时刻(18:30)、ISO 时间、every 1h。",
289
+ description:
290
+ "当用户说「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」时用。" +
291
+ "→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h。" +
292
+ "会写盘,需要人工确认(只在用户明确要求时调用)。",
280
293
  parameters: SCHEDULE_PARAMETERS,
281
294
  async execute(_toolCallId: string, params: Record<string, any>) {
282
295
  try {
@@ -321,6 +334,55 @@ export default function activate(pi: PactiveExtensionAPI): void {
321
334
  },
322
335
  });
323
336
 
337
+ pi.registerTool({
338
+ name: "pactive_config",
339
+ label: "Pactive Config",
340
+ description:
341
+ "当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」时用。" +
342
+ "→ 读配置值与默认值;要改配置时只返回一条需要人工确认的可复制命令。只读,不落盘。",
343
+ parameters: CONFIG_PARAMETERS,
344
+ async execute(_toolCallId: string, params: Record<string, any>) {
345
+ try {
346
+ // Read-only by construction: a write request returns the command, never a write (contract §2.3).
347
+ const core = await loadCore();
348
+ const described = core.config?.describe ? core.config.describe() : { schema: {}, defaults: {} };
349
+ const known: string[] = Object.keys(described.defaults);
350
+ const key = typeof params?.key === "string" ? params.key.trim() : "";
351
+ const rawValue = params?.value === undefined || params?.value === null ? "" : String(params.value).trim();
352
+ if (key !== "" && !known.includes(key)) {
353
+ return textResult(`pactive_config 失败:未知配置键(${key});已知:${known.join(", ")}`, { error: true });
354
+ }
355
+ const paths = core.resolvePaths(HOST_KIND, process.env);
356
+ const state = (await readState(core, paths)) ?? core.defaultState();
357
+ const values = (key === "" ? known : [key]).map((k: string) => ({
358
+ key: k,
359
+ value: core.config?.get ? core.config.get(state, k) : null,
360
+ default: described.defaults[k] ?? null,
361
+ humanGated: described.schema?.[k]?.humanGated === true,
362
+ }));
363
+ if (rawValue === "") {
364
+ return textResult(JSON.stringify({ ok: true, readOnly: true, wrote: false, values }, null, 2), { readOnly: true });
365
+ }
366
+ return textResult(
367
+ JSON.stringify(
368
+ {
369
+ ok: false,
370
+ wrote: false,
371
+ reason: "need_human_confirmation",
372
+ command: `pactive config set ${key} ${rawValue} --confirm`,
373
+ hint: "不要自己改状态文件;把 command 原样给用户,等他执行后下一轮用 pactive_status 复核。",
374
+ values,
375
+ },
376
+ null,
377
+ 2,
378
+ ),
379
+ { needHumanConfirmation: true },
380
+ );
381
+ } catch (err) {
382
+ return textResult(`pactive_config 失败:${errorText(err)}`, { error: true });
383
+ }
384
+ },
385
+ });
324
386
  pi.registerCommand("pactive", {
325
387
  description: "Pactive:status | tasks | schedule <when> <prompt> | help",
326
388
  async handler(args: string, ctx: PactiveUiContext) {
@@ -1,28 +1,41 @@
1
1
  ---
2
2
  name: pactive
3
3
  description: >-
4
- Correct usage of the pactive proactive-assistant extension (tools pactive_status / pactive_schedule /
5
- pactive_tell / pactive_history and the "/pactive" command family). Use when the task involves checking
6
- whether the idle trigger fired or why it did not, scheduling work for later (one-shot or
7
- repeating), reading the pactive event ledger, or sending a message to another session on the same
8
- machine. Covers the semantics traps (queued is not delivered, idle is disabled by default,
9
- human-gated config needs CLI --confirm, the Stop hook blocks at most once per turn) and the
10
- "when" syntax (10m / 18:30 / ISO 8601 / every 1h).
4
+ 用自然语言操作 pactive(主动提醒 / 定时任务 / 跨会话消息 / 事件账本)。用户说「我现在闲不闲」
5
+ 「10 分钟后提醒我喝水」「帮我开启主动提醒」「刚才主动提醒都发生了什么」时加载本技能。
6
+ 写操作一律不静默落盘:给出可复制命令 `pactive config set … --confirm`,用户执行后下一轮用
7
+ `pactive_status` / `pactive_config` 复核;含 reason 枚举、「when」语法与排障。
11
8
  ---
12
9
 
13
- # pactive:怎么正确用它
10
+ # pactive:用户会怎么说,你就怎么做
14
11
 
15
- pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定时任务**、**跨会话消息**与**事件账本**。
12
+ **默认全部用自然语言。** 用户不会说「把 `idle.enabled` 置为 true」,他会说「帮我开启主动提醒」。
13
+ 你的工作是把这句话翻译成**一次只读查询**,或**一条可复制的命令**——不是替他改状态文件。
16
14
 
17
- ## 工具与命令
15
+ ## 用户会怎么说 → 你做什么
18
16
 
19
- | 入口 | 作用 |
17
+ | 用户说 | 你做什么 |
20
18
  |---|---|
21
- | `pactive_status` / `/pactive status` | 空闲判定与 `reason`、护栏四闸门、定时任务数、最近活动(只读) |
22
- | `pactive_schedule` / `/pactive schedule <when> <prompt>` | 登记一个定时任务(真正落盘,重启后仍在) |
23
- | `pactive_tell <to> <body>` | 给同机另一个会话投递消息(离线时排队,不伪装成功) |
24
- | `pactive_history [n]` | 读事件账本尾部:触发 → 投递 → 回应 → 结账 |
25
- | `/pactive tasks` | 列出待触发的定时任务 |
19
+ | 「我现在闲不闲?」「为什么没触发?」 | 调 `pactive_status`,读 `idle.reason` 与护栏结论,照实解释 |
20
+ | 「主动提醒开启了吗?」「阈值是多少?」 | 调 `pactive_config`(只读),报当前值与默认值 |
21
+ | 「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」 | 调 `pactive_schedule` `{prompt, when}`(用户已明确要求,会写盘) |
22
+ | 「帮我开启主动提醒」「把阈值改成一小时」 | **不写**:调 `pactive_config` 拿命令,把**一条**命令交给用户 |
23
+ | 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
24
+ | 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
25
+ | 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
26
+
27
+ ## 写请求的三步(硬规则)
28
+
29
+ 1. **不静默写状态。** 开关、阈值、取消任务一律不由你落盘:不要改 `state.json`,不要绕过 `--confirm`。
30
+ 模型侧改配置只会得到 `need_human_confirmation`,这是设计,不是故障。
31
+ 2. **给一条可直接复制的命令。** 只给一条,不要列一堆选项:
32
+ ```
33
+ pactive config set idle.enabled true --confirm
34
+ pactive cancel <taskId> --confirm
35
+ ```
36
+ 同时说明它会写盘、写到哪里(默认 `~/.pactive/state.json`,可用 `PACTIVE_STATE_DIR` 覆盖)。
37
+ 3. **下一轮复核。** 用户说「好了/执行了」之后,再调 `pactive_status`(或 `pactive_config`)确认真的生效,
38
+ 然后回报实测值。**没复核就说「已开启」等于撒谎。**
26
39
 
27
40
  ## 关键语义(别踩)
28
41
 
@@ -31,8 +44,7 @@ pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定
31
44
  没触发时先看 `reason`,不要猜。
32
45
  - **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
33
46
  - **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
34
- - **配置是人工闸门**:`config` 只能由人用 CLI `--confirm` 改;模型/MCP 侧改配置一律得到
35
- `need_human_confirmation`,且配置不变。
47
+ - **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
36
48
  - **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
37
49
 
38
50
  ## `when` 语法
@@ -40,8 +52,19 @@ pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定
40
52
  `10m` / `2h` / `30s`(相对时长)、`18:30`(当天时刻,已过则顺延到明天)、
41
53
  ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
42
54
 
43
- ## 排障
55
+ ## 排障(先查,再说话)
44
56
 
45
- - 状态面板里 `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
57
+ - `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
46
58
  - 任务登记了但没触发 → 先看 `pactive_history` 里有没有 `triggered` 记录;没有说明调度器没跑到。
47
59
  - 消息一直是 `queued` → 对端不在线;看 `pactive_status` 的 peers。
60
+ - 「没记录」只说明**没有证据**,不等于「没问题」——照实这么说。
61
+
62
+ ## 命令面(给人用的,你只在需要时引用)
63
+
64
+ ```
65
+ pactive config get [<key>] # 只读:当前值与默认值
66
+ pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
67
+ pactive schedule "<prompt>" --when <when> [--confirm]
68
+ pactive tasks # id / 类型 / 下次到期 / 归属
69
+ pactive cancel <taskId> [--confirm]
70
+ ```
@@ -1,28 +1,41 @@
1
1
  ---
2
2
  name: pactive
3
3
  description: >-
4
- Correct usage of the pactive proactive-assistant extension (tools pactive_status / pactive_schedule /
5
- pactive_tell / pactive_history and the "/pactive" command family). Use when the task involves checking
6
- whether the idle trigger fired or why it did not, scheduling work for later (one-shot or
7
- repeating), reading the pactive event ledger, or sending a message to another session on the same
8
- machine. Covers the semantics traps (queued is not delivered, idle is disabled by default,
9
- human-gated config needs CLI --confirm, the Stop hook blocks at most once per turn) and the
10
- "when" syntax (10m / 18:30 / ISO 8601 / every 1h).
4
+ 用自然语言操作 pactive(主动提醒 / 定时任务 / 跨会话消息 / 事件账本)。用户说「我现在闲不闲」
5
+ 「10 分钟后提醒我喝水」「帮我开启主动提醒」「刚才主动提醒都发生了什么」时加载本技能。
6
+ 写操作一律不静默落盘:给出可复制命令 `pactive config set … --confirm`,用户执行后下一轮用
7
+ `pactive_status` / `pactive_config` 复核;含 reason 枚举、「when」语法与排障。
11
8
  ---
12
9
 
13
- # pactive:怎么正确用它
10
+ # pactive:用户会怎么说,你就怎么做
14
11
 
15
- pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定时任务**、**跨会话消息**与**事件账本**。
12
+ **默认全部用自然语言。** 用户不会说「把 `idle.enabled` 置为 true」,他会说「帮我开启主动提醒」。
13
+ 你的工作是把这句话翻译成**一次只读查询**,或**一条可复制的命令**——不是替他改状态文件。
16
14
 
17
- ## 工具与命令
15
+ ## 用户会怎么说 → 你做什么
18
16
 
19
- | 入口 | 作用 |
17
+ | 用户说 | 你做什么 |
20
18
  |---|---|
21
- | `pactive_status` / `/pactive status` | 空闲判定与 `reason`、护栏四闸门、定时任务数、最近活动(只读) |
22
- | `pactive_schedule` / `/pactive schedule <when> <prompt>` | 登记一个定时任务(真正落盘,重启后仍在) |
23
- | `pactive_tell <to> <body>` | 给同机另一个会话投递消息(离线时排队,不伪装成功) |
24
- | `pactive_history [n]` | 读事件账本尾部:触发 → 投递 → 回应 → 结账 |
25
- | `/pactive tasks` | 列出待触发的定时任务 |
19
+ | 「我现在闲不闲?」「为什么没触发?」 | 调 `pactive_status`,读 `idle.reason` 与护栏结论,照实解释 |
20
+ | 「主动提醒开启了吗?」「阈值是多少?」 | 调 `pactive_config`(只读),报当前值与默认值 |
21
+ | 「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」 | 调 `pactive_schedule` `{prompt, when}`(用户已明确要求,会写盘) |
22
+ | 「帮我开启主动提醒」「把阈值改成一小时」 | **不写**:调 `pactive_config` 拿命令,把**一条**命令交给用户 |
23
+ | 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
24
+ | 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
25
+ | 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
26
+
27
+ ## 写请求的三步(硬规则)
28
+
29
+ 1. **不静默写状态。** 开关、阈值、取消任务一律不由你落盘:不要改 `state.json`,不要绕过 `--confirm`。
30
+ 模型侧改配置只会得到 `need_human_confirmation`,这是设计,不是故障。
31
+ 2. **给一条可直接复制的命令。** 只给一条,不要列一堆选项:
32
+ ```
33
+ pactive config set idle.enabled true --confirm
34
+ pactive cancel <taskId> --confirm
35
+ ```
36
+ 同时说明它会写盘、写到哪里(默认 `~/.pactive/state.json`,可用 `PACTIVE_STATE_DIR` 覆盖)。
37
+ 3. **下一轮复核。** 用户说「好了/执行了」之后,再调 `pactive_status`(或 `pactive_config`)确认真的生效,
38
+ 然后回报实测值。**没复核就说「已开启」等于撒谎。**
26
39
 
27
40
  ## 关键语义(别踩)
28
41
 
@@ -31,8 +44,7 @@ pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定
31
44
  没触发时先看 `reason`,不要猜。
32
45
  - **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
33
46
  - **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
34
- - **配置是人工闸门**:`config` 只能由人用 CLI `--confirm` 改;模型/MCP 侧改配置一律得到
35
- `need_human_confirmation`,且配置不变。
47
+ - **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
36
48
  - **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
37
49
 
38
50
  ## `when` 语法
@@ -40,8 +52,19 @@ pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定
40
52
  `10m` / `2h` / `30s`(相对时长)、`18:30`(当天时刻,已过则顺延到明天)、
41
53
  ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
42
54
 
43
- ## 排障
55
+ ## 排障(先查,再说话)
44
56
 
45
- - 状态面板里 `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
57
+ - `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
46
58
  - 任务登记了但没触发 → 先看 `pactive_history` 里有没有 `triggered` 记录;没有说明调度器没跑到。
47
59
  - 消息一直是 `queued` → 对端不在线;看 `pactive_status` 的 peers。
60
+ - 「没记录」只说明**没有证据**,不等于「没问题」——照实这么说。
61
+
62
+ ## 命令面(给人用的,你只在需要时引用)
63
+
64
+ ```
65
+ pactive config get [<key>] # 只读:当前值与默认值
66
+ pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
67
+ pactive schedule "<prompt>" --when <when> [--confirm]
68
+ pactive tasks # id / 类型 / 下次到期 / 归属
69
+ pactive cancel <taskId> [--confirm]
70
+ ```
@@ -28,10 +28,12 @@ export const schedule = {
28
28
  if (everyMs <= 0) throw pactiveError('TASK_INTERVAL_REQUIRED', 'an "every" task needs a positive interval');
29
29
  dueAt = ts + everyMs;
30
30
  } else {
31
+ // A relative delay may arrive either as a duration (delayMs/afterMs/ms) or already
32
+ // resolved to an absolute time (spec.at — what every host-surface parseWhen produces).
31
33
  const delay = Number.isFinite(spec.delayMs) ? spec.delayMs
32
34
  : Number.isFinite(spec.afterMs) ? spec.afterMs
33
- : Number.isFinite(spec.ms) ? spec.ms : 0;
34
- dueAt = ts + delay;
35
+ : Number.isFinite(spec.ms) ? spec.ms : null;
36
+ dueAt = delay === null && Number.isFinite(spec.at) ? spec.at : ts + (delay ?? 0);
35
37
  }
36
38
 
37
39
  const owner = spec.owner ?? state.identity?.ownerKey ?? '';
@@ -48,6 +50,8 @@ export const schedule = {
48
50
  everyMs,
49
51
  label: spec.label ?? null,
50
52
  payload: spec.payload ?? null,
53
+ // The prompt is what the trigger injects later; dropping it registers an empty reminder.
54
+ prompt: spec.prompt ?? spec.content ?? null,
51
55
  createdAt: ts,
52
56
  lastRunAt: 0,
53
57
  active: true,