pactive 0.0.3-alpha.1 → 0.0.4-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/pi/extension.ts CHANGED
@@ -194,12 +194,19 @@ function textResult(text: string, details?: Record<string, unknown>): PactiveToo
194
194
  return details ? { content: [{ type: "text", text }], details } : { content: [{ type: "text", text }] };
195
195
  }
196
196
 
197
- const STATUS_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
197
+ const STATUS_PARAMETERS = {
198
+ type: "object",
199
+ properties: { assistant: { type: "string", description: "只读:助理名或 id;不传 = 全部助理概览" } },
200
+ additionalProperties: false,
201
+ };
202
+ const ASSISTANTS_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
203
+ const FLEET_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
198
204
  const SCHEDULE_PARAMETERS = {
199
205
  type: "object",
200
206
  properties: {
201
207
  prompt: { type: "string", description: "任务正文(到点注入给会话的内容)" },
202
208
  when: { type: "string", description: "触发时间:时长(10m)/ 时刻(18:30)/ ISO 8601 / every 1h" },
209
+ assistant: { type: "string", description: "记在哪个助理名下(名字或 id);不传 = 默认助理" },
203
210
  },
204
211
  required: ["prompt"],
205
212
  additionalProperties: false,
@@ -207,12 +214,62 @@ const SCHEDULE_PARAMETERS = {
207
214
  const CONFIG_PARAMETERS = {
208
215
  type: "object",
209
216
  properties: {
210
- key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.*)" },
217
+ key: { type: "string", description: "配置键;缺省返回全部(idle.enabled / idle.thresholdMs / policy.* / globalGuard.hourlyLimit)" },
211
218
  value: { type: "string", description: "想改成的新值;给了就只返回 `pactive config set <key> <value> --confirm`,不落盘" },
219
+ kind: { type: "string", description: "提议哪种写操作:assistant.add | assistant.remove | assistant.enable(只返回命令,不落盘)" },
220
+ name: { type: "string", description: "kind=assistant.* 时要操作的助理名或 id(缺 id 时用名字)" },
212
221
  },
213
222
  additionalProperties: false,
214
223
  };
215
224
 
225
+ /** One assistant as the model needs to see it: switch, pending work, next action. */
226
+ function assistantView(core: any, state: any, record: any): Record<string, unknown> {
227
+ const all = core.schedule?.list ? core.schedule.list(state, record.assistantId) : [];
228
+ const active = (Array.isArray(all) ? all : []).filter((t: any) => t && t.active !== false);
229
+ const dueAt = active.map((t: any) => t.dueAt).filter((n: unknown) => Number.isFinite(n));
230
+ return {
231
+ assistantId: record.assistantId,
232
+ name: record.name,
233
+ title: record.title,
234
+ enabled: record.enabled === true,
235
+ role: record.role ?? "",
236
+ tasks: active.length,
237
+ nextDueAt: dueAt.length > 0 ? Math.min(...dueAt) : null,
238
+ lastFiredAt: record.lastFiredAt ?? 0,
239
+ firedCount: record.firedCount ?? 0,
240
+ policy: record.policy ?? {},
241
+ };
242
+ }
243
+
244
+ function assistantOverview(core: any, state: any): any[] {
245
+ const list = core.assistants?.list ? core.assistants.list(state) : [];
246
+ return list.map((record: any) => assistantView(core, state, record));
247
+ }
248
+
249
+ /**
250
+ * I66: the model only *proposes* — every assistant write comes back as one copy-pasteable
251
+ * command whose `--confirm` a human has to type. Nothing here writes.
252
+ */
253
+ function assistCommandFor(kind: string, name: string, value: string, fleet: any[]): { ok: boolean; command?: string; reason?: string } {
254
+ const known = fleet.map((a) => a.name);
255
+ if (kind === "assistant.add") {
256
+ if (name === "") return { ok: false, reason: "kind=assistant.add 需要 name" };
257
+ if (known.includes(name)) return { ok: false, reason: `助理已经存在(${name}),已有:${known.join(", ")}` };
258
+ return { ok: true, command: `pactive assistant add ${name} --confirm` };
259
+ }
260
+ if (kind === "assistant.remove" || kind === "assistant.enable") {
261
+ if (name === "") return { ok: false, reason: `kind=${kind} 需要 name` };
262
+ const hit = fleet.find((a) => a.name === name || a.assistantId === name);
263
+ if (!hit) return { ok: false, reason: `未知助理(${name});已有:${known.join(", ") || "(none yet)"}` };
264
+ if (kind === "assistant.enable") {
265
+ const on = value === "" ? !hit.enabled : value === "true" || value === "1" || value === "on";
266
+ return { ok: true, command: `pactive assistant ${on ? "enable" : "disable"} ${hit.name} --confirm` };
267
+ }
268
+ return { ok: true, command: `pactive assistant rm ${hit.name} --confirm` };
269
+ }
270
+ return { ok: false, reason: `未知 kind(${kind});支持 assistant.add / assistant.remove / assistant.enable` };
271
+ }
272
+
216
273
  /** Mark an activity kind; never throws. */
217
274
  async function markActivity(kind: "user" | "agent" | "session-start" | "session-end" | "tool"): Promise<void> {
218
275
  try {
@@ -246,29 +303,59 @@ export default function activate(pi: PactiveExtensionAPI): void {
246
303
  name: "pactive_status",
247
304
  label: "Pactive Status",
248
305
  description:
249
- "当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」时用。" +
250
- "→ 读当前空闲判定、reason、护栏四闸门、任务数与最近活动。只读,不落盘。",
306
+ "当用户问「我现在闲不闲」「主动提醒开启了吗」「为什么没触发」「刚才主动做了什么」「盯盘那个助理怎么样了」时用。" +
307
+ "→ 读空闲判定、reason、护栏闸门与任务数;给 assistant 看该助理,不给看全部助理概览。只读,不落盘。",
251
308
  parameters: STATUS_PARAMETERS,
252
- async execute() {
309
+ async execute(_toolCallId: string, params: Record<string, any>) {
253
310
  try {
254
311
  const core = await loadCore();
255
312
  const now = Date.now();
256
313
  const paths = core.resolvePaths(HOST_KIND, process.env);
257
314
  const state = (await readState(core, paths)) ?? core.defaultState();
315
+ const globalGuard = state.globalGuard ?? null;
316
+ const activity = core.getActivity ? core.getActivity(state) : null;
317
+ const requested = typeof params?.assistant === "string" ? params.assistant.trim() : "";
318
+ if (requested !== "") {
319
+ const record = core.assistants?.resolve ? core.assistants.resolve(state, requested) : null;
320
+ if (!record) {
321
+ const names = assistantOverview(core, state).map((a) => a.name);
322
+ return textResult(
323
+ `pactive_status 失败:未知助理(${requested});已知:${names.join(", ") || "(none yet)"}`,
324
+ { error: true },
325
+ );
326
+ }
327
+ const idle = core.evaluateIdle
328
+ ? core.evaluateIdle(state, now, {}, record.assistantId)
329
+ : { idle: false, reason: "requires-action" };
330
+ const guard = core.checkGuard
331
+ ? core.checkGuard(state, now, record.assistantId)
332
+ : { pass: false, reason: "requires-action" };
333
+ return textResult(
334
+ JSON.stringify(
335
+ { host: HOST_KIND, now, assistant: assistantView(core, state, record), idle, guard, globalGuard, activity },
336
+ null,
337
+ 2,
338
+ ),
339
+ { host: HOST_KIND },
340
+ );
341
+ }
258
342
  const idle = core.evaluateIdle
259
343
  ? core.evaluateIdle(state, now, state.policy ?? {})
260
344
  : { idle: false, reason: "requires-action" };
261
345
  const guard = core.checkGuard ? core.checkGuard(state, now) : { pass: false, reason: "requires-action" };
262
346
  const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
263
- const activity = core.getActivity ? core.getActivity(state) : null;
347
+ const fleet = assistantOverview(core, state);
264
348
  return textResult(
265
349
  JSON.stringify(
266
350
  {
267
351
  host: HOST_KIND,
268
352
  now,
269
353
  enabled: state.idle?.enabled ?? false,
354
+ assistants: fleet,
355
+ assistantCount: fleet.length,
270
356
  idle,
271
357
  guard,
358
+ globalGuard,
272
359
  tasks: Array.isArray(tasks) ? tasks.length : 0,
273
360
  activity,
274
361
  },
@@ -283,12 +370,55 @@ export default function activate(pi: PactiveExtensionAPI): void {
283
370
  },
284
371
  });
285
372
 
373
+ pi.registerTool({
374
+ name: "pactive_assistants",
375
+ label: "Pactive Assistants",
376
+ description:
377
+ "当用户问「我有几个助理」「有哪些助理」「盯盘助理开着吗」「各自下次什么时候动作」时用。" +
378
+ "→ 列出全部助理的名称、开关、任务数、下次到期与触发次数。只读,不落盘。",
379
+ parameters: ASSISTANTS_PARAMETERS,
380
+ async execute() {
381
+ try {
382
+ const core = await loadCore();
383
+ const paths = core.resolvePaths(HOST_KIND, process.env);
384
+ const state = (await readState(core, paths)) ?? core.defaultState();
385
+ const fleet = assistantOverview(core, state);
386
+ return textResult(JSON.stringify({ ok: true, count: fleet.length, assistants: fleet }, null, 2), {
387
+ count: fleet.length,
388
+ });
389
+ } catch (err) {
390
+ return textResult(`pactive_assistants 失败:${errorText(err)}`, { error: true });
391
+ }
392
+ },
393
+ });
394
+
395
+ pi.registerTool({
396
+ name: "pactive_fleet",
397
+ label: "Pactive Fleet",
398
+ description:
399
+ "当用户问「我有几个助理在跑」「盯盘那个助理在哪个 pane」「助理的窗口还活着吗」「上次主动投递送达了吗」时用。" +
400
+ "→ 读助理 ↔ herdr pane 绑定、agent 状态与最近一次投递;pane 状态来自 herdr,未观测到就写 unobserved。只读,不落盘。",
401
+ parameters: FLEET_PARAMETERS,
402
+ async execute() {
403
+ try {
404
+ const core = await loadCore();
405
+ const fleetView = await import(new URL("../src/hosts/herdr/fleet-view.mjs", import.meta.url).href);
406
+ const paths = core.resolvePaths(HOST_KIND, process.env);
407
+ const state = (await readState(core, paths)) ?? core.defaultState();
408
+ const payload = fleetView.fleetPayload(core, state, paths, process.env);
409
+ return textResult(JSON.stringify(payload, null, 2), { count: payload.count });
410
+ } catch (err) {
411
+ return textResult(`pactive_fleet 失败:${errorText(err)}`, { error: true });
412
+ }
413
+ },
414
+ });
415
+
286
416
  pi.registerTool({
287
417
  name: "pactive_schedule",
288
418
  label: "Pactive Schedule",
289
419
  description:
290
420
  "当用户说「10 分钟后提醒我喝水」「18:30 提醒我开会」「每小时检查一次」时用。" +
291
- "→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h。" +
421
+ "→ 登记一条定时任务并立即落盘;when 支持 10m / 18:30 / ISO 8601 / every 1h;assistant 不填就记在默认助理名下。" +
292
422
  "会写盘,需要人工确认(只在用户明确要求时调用)。",
293
423
  parameters: SCHEDULE_PARAMETERS,
294
424
  async execute(_toolCallId: string, params: Record<string, any>) {
@@ -302,22 +432,40 @@ export default function activate(pi: PactiveExtensionAPI): void {
302
432
  }
303
433
  const core = await loadCore();
304
434
  const paths = core.resolvePaths(HOST_KIND, process.env);
435
+ const requested = typeof params?.assistant === "string" ? params.assistant.trim() : "";
436
+ let assistantId: string | null = null;
437
+ if (requested !== "") {
438
+ const before = (await readState(core, paths)) ?? core.defaultState();
439
+ const record = core.assistants?.resolve ? core.assistants.resolve(before, requested) : null;
440
+ if (!record) {
441
+ const names = assistantOverview(core, before).map((a) => a.name);
442
+ return textResult(
443
+ `pactive_schedule 失败:未知助理(${requested});已知:${names.join(", ") || "(none yet)"}`,
444
+ { error: true },
445
+ );
446
+ }
447
+ assistantId = record.assistantId;
448
+ }
305
449
  const next = await mutateState(core, paths, (state) => {
450
+ const targetId =
451
+ assistantId ??
452
+ (core.assistants?.ensureDefault ? core.assistants.ensureDefault(state).assistantId : null);
306
453
  const out = core.schedule.add(
307
454
  state,
308
- { ...spec, prompt, content: prompt, owner: state.identity?.agentId },
455
+ { ...spec, prompt, content: prompt, owner: state.identity?.agentId, assistantId: targetId },
309
456
  now,
310
457
  );
311
458
  if (out && typeof out === "object" && out.state) return out.state;
312
459
  return out && Array.isArray(out.tasks) ? out : state;
313
460
  });
314
- const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
461
+ const tasks = core.schedule?.list ? core.schedule.list(next, assistantId ?? undefined) : (next.tasks ?? []);
315
462
  const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
316
463
  return textResult(
317
464
  JSON.stringify(
318
465
  {
319
466
  ok: true,
320
467
  taskId: created?.id ?? created?.taskId ?? null,
468
+ assistantId: created?.assistantId ?? assistantId,
321
469
  dueAt: created?.dueAt ?? spec.at ?? null,
322
470
  kind: spec.kind,
323
471
  when: spec.when,
@@ -338,22 +486,47 @@ export default function activate(pi: PactiveExtensionAPI): void {
338
486
  name: "pactive_config",
339
487
  label: "Pactive Config",
340
488
  description:
341
- "当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」时用。" +
342
- "→ 读配置值与默认值;要改配置时只返回一条需要人工确认的可复制命令。只读,不落盘。",
489
+ "当用户问「主动提醒的开关开着吗」「阈值是多少」「安静时间是几点」「帮我把主动提醒打开」「再给我加一个盯盘的助理」时用。" +
490
+ "→ 读配置值与默认值;要改配置或增删 / 开关助理时,只返回一条需要人工确认的可复制命令。只读,不落盘。",
343
491
  parameters: CONFIG_PARAMETERS,
344
492
  async execute(_toolCallId: string, params: Record<string, any>) {
345
493
  try {
346
- // Read-only by construction: a write request returns the command, never a write (contract §2.3).
494
+ // Read-only by construction: a write request returns the command, never a write (contract §2.3 / I66).
347
495
  const core = await loadCore();
348
496
  const described = core.config?.describe ? core.config.describe() : { schema: {}, defaults: {} };
349
497
  const known: string[] = Object.keys(described.defaults);
350
498
  const key = typeof params?.key === "string" ? params.key.trim() : "";
351
499
  const rawValue = params?.value === undefined || params?.value === null ? "" : String(params.value).trim();
500
+ const kind = typeof params?.kind === "string" ? params.kind.trim() : "";
501
+ const name = typeof params?.name === "string" ? params.name.trim() : "";
502
+ const paths = core.resolvePaths(HOST_KIND, process.env);
503
+ const state = (await readState(core, paths)) ?? core.defaultState();
504
+ const fleet = assistantOverview(core, state);
505
+
506
+ if (kind !== "") {
507
+ const command = assistCommandFor(kind, name, rawValue, fleet);
508
+ if (!command.ok) return textResult(`pactive_config 失败:${command.reason}`, { error: true });
509
+ return textResult(
510
+ JSON.stringify(
511
+ {
512
+ ok: false,
513
+ wrote: false,
514
+ reason: "need_human_confirmation",
515
+ command: command.command,
516
+ kind,
517
+ before: { assistants: fleet.length, names: fleet.map((a) => a.name) },
518
+ hint: "不要自己改状态文件;把 command 原样给用户,等他执行后下一轮用 pactive_assistants 复核。",
519
+ },
520
+ null,
521
+ 2,
522
+ ),
523
+ { needHumanConfirmation: true },
524
+ );
525
+ }
526
+
352
527
  if (key !== "" && !known.includes(key)) {
353
528
  return textResult(`pactive_config 失败:未知配置键(${key});已知:${known.join(", ")}`, { error: true });
354
529
  }
355
- const paths = core.resolvePaths(HOST_KIND, process.env);
356
- const state = (await readState(core, paths)) ?? core.defaultState();
357
530
  const values = (key === "" ? known : [key]).map((k: string) => ({
358
531
  key: k,
359
532
  value: core.config?.get ? core.config.get(state, k) : null,
@@ -361,7 +534,10 @@ export default function activate(pi: PactiveExtensionAPI): void {
361
534
  humanGated: described.schema?.[k]?.humanGated === true,
362
535
  }));
363
536
  if (rawValue === "") {
364
- return textResult(JSON.stringify({ ok: true, readOnly: true, wrote: false, values }, null, 2), { readOnly: true });
537
+ return textResult(
538
+ JSON.stringify({ ok: true, readOnly: true, wrote: false, values, assistants: fleet.length }, null, 2),
539
+ { readOnly: true },
540
+ );
365
541
  }
366
542
  return textResult(
367
543
  JSON.stringify(
@@ -23,6 +23,9 @@ description: >-
23
23
  | 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
24
24
  | 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
25
25
  | 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
26
+ | 「我有几个助理?」「盯盘助理开着吗?」 | 调 `pactive_assistants`(只读);要单个详情调 `pactive_status` `{assistant}` |
27
+ | 「再给我加一个盯盘的助理」「把巡检关掉」 | **不写**:调 `pactive_config` `{kind:"assistant.add"\|"assistant.remove"\|"assistant.enable", name}` 拿命令 |
28
+ | 「10 分钟后提醒我看盘片」并且有多个助理 | 调 `pactive_schedule` `{prompt, when, assistant}`,把任务记到指定助理名下 |
26
29
 
27
30
  ## 写请求的三步(硬规则)
28
31
 
@@ -45,6 +48,11 @@ description: >-
45
48
  - **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
46
49
  - **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
47
50
  - **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
51
+ - **助理是一等对象**(I61–I66):`assistantId` 形如 `盯盘#<短哈希>`;建/删/开关都走 `pactive assistant … --confirm`;
52
+ 新建助理默认 `enabled=false`(装上不等于打扰);删助理会连带删掉它的任务,**不留孤儿**;重命名不改 id。
53
+ - **按助理隔离 + 全局闸门**:每个助理各有一份计数/冷却,A 的冷却压不住 B;但全局每小时总投递有上限(默认 24),
54
+ 超限时每个助理都记 `reason=global-limit`,不会静默丢。
55
+ - **`tick` 是最小驱动**:`pactive tick` 跑一轮「到期任务 + 空闲判定」,可投递项落 ledger(`delivered` / `blocked`+reason)。
48
56
  - **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
49
57
 
50
58
  ## `when` 语法
@@ -64,7 +72,13 @@ ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
64
72
  ```
65
73
  pactive config get [<key>] # 只读:当前值与默认值
66
74
  pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
67
- pactive schedule "<prompt>" --when <when> [--confirm]
68
- pactive tasks # id / 类型 / 下次到期 / 归属
75
+ pactive schedule "<prompt>" --when <when> [--assistant <名字>] [--confirm]
76
+ pactive tasks [--assistant <名字>] # id / 类型 / 下次到期 / 归属
69
77
  pactive cancel <taskId> [--confirm]
78
+ pactive tick [--assistant <名字>] # 跑一轮:到期任务 + 空闲判定 → ledger
79
+ pactive assistant add <name> [--title …] [--role …] [--scope <glob>] --confirm
80
+ pactive assistant ls | show <name|id> # 只读
81
+ pactive assistant enable|disable <name|id> --confirm
82
+ pactive assistant rename <name|id> <newName> --confirm
83
+ pactive assistant rm <name|id> --confirm # 连带删它的任务
70
84
  ```
@@ -23,6 +23,9 @@ description: >-
23
23
  | 「把那个定时任务取消」 | **不写**:给出 `pactive cancel <taskId> --confirm`,让用户自己执行 |
24
24
  | 「刚才主动提醒都发生了什么?」 | 调 `pactive_history`,只报可核验的事实(时间戳 + 事件种类) |
25
25
  | 「告诉另一个会话我有空」 | 调 `pactive_tell` `{to, body}`;回执是 `queued` 就照实说**还没送到** |
26
+ | 「我有几个助理?」「盯盘助理开着吗?」 | 调 `pactive_assistants`(只读);要单个详情调 `pactive_status` `{assistant}` |
27
+ | 「再给我加一个盯盘的助理」「把巡检关掉」 | **不写**:调 `pactive_config` `{kind:"assistant.add"\|"assistant.remove"\|"assistant.enable", name}` 拿命令 |
28
+ | 「10 分钟后提醒我看盘片」并且有多个助理 | 调 `pactive_schedule` `{prompt, when, assistant}`,把任务记到指定助理名下 |
26
29
 
27
30
  ## 写请求的三步(硬规则)
28
31
 
@@ -45,6 +48,11 @@ description: >-
45
48
  - **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
46
49
  - **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
47
50
  - **配置是人工闸门**:只有人能加 `--confirm`;你给命令,他执行。
51
+ - **助理是一等对象**(I61–I66):`assistantId` 形如 `盯盘#<短哈希>`;建/删/开关都走 `pactive assistant … --confirm`;
52
+ 新建助理默认 `enabled=false`(装上不等于打扰);删助理会连带删掉它的任务,**不留孤儿**;重命名不改 id。
53
+ - **按助理隔离 + 全局闸门**:每个助理各有一份计数/冷却,A 的冷却压不住 B;但全局每小时总投递有上限(默认 24),
54
+ 超限时每个助理都记 `reason=global-limit`,不会静默丢。
55
+ - **`tick` 是最小驱动**:`pactive tick` 跑一轮「到期任务 + 空闲判定」,可投递项落 ledger(`delivered` / `blocked`+reason)。
48
56
  - **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
49
57
 
50
58
  ## `when` 语法
@@ -64,7 +72,13 @@ ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
64
72
  ```
65
73
  pactive config get [<key>] # 只读:当前值与默认值
66
74
  pactive config set <key> <value> --confirm # 写;缺 --confirm → exit 2 且零写盘
67
- pactive schedule "<prompt>" --when <when> [--confirm]
68
- pactive tasks # id / 类型 / 下次到期 / 归属
75
+ pactive schedule "<prompt>" --when <when> [--assistant <名字>] [--confirm]
76
+ pactive tasks [--assistant <名字>] # id / 类型 / 下次到期 / 归属
69
77
  pactive cancel <taskId> [--confirm]
78
+ pactive tick [--assistant <名字>] # 跑一轮:到期任务 + 空闲判定 → ledger
79
+ pactive assistant add <name> [--title …] [--role …] [--scope <glob>] --confirm
80
+ pactive assistant ls | show <name|id> # 只读
81
+ pactive assistant enable|disable <name|id> --confirm
82
+ pactive assistant rename <name|id> <newName> --confirm
83
+ pactive assistant rm <name|id> --confirm # 连带删它的任务
70
84
  ```
@@ -0,0 +1,237 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import { pactiveError } from './errors.mjs';
4
+
5
+ /**
6
+ * Assistants: the fleet of logical proactive helpers that share one state root.
7
+ *
8
+ * Assistant ids are `<name>#<ownerKey short hash>`: stable and readable, and a rename
9
+ * keeps the id (contract §2 / I61 / I65). The `default` assistant is the implicit
10
+ * single assistant of a schema-v1 state; it is created lazily so a fresh install
11
+ * lists no assistants until somebody names one.
12
+ */
13
+
14
+ export const DEFAULT_ASSISTANT_NAME = 'default';
15
+
16
+ const POLICY_KEYS = ['hourlyLimit', 'minIntervalMs', 'cooldownMs', 'quietStart', 'quietEnd'];
17
+
18
+ /** `<name>#<12 hex of sha256(ownerKey)>` — deterministic per owner, stable across restarts. */
19
+ export function assistantIdFor(name, ownerKey = '') {
20
+ const hash = crypto.createHash('sha256').update(String(ownerKey ?? '')).digest('hex').slice(0, 12);
21
+ return `${slug(name)}#${hash}`;
22
+ }
23
+
24
+ export function defaultAssistantId(ownerKey = '') {
25
+ return assistantIdFor(DEFAULT_ASSISTANT_NAME, ownerKey);
26
+ }
27
+
28
+ function slug(name) {
29
+ const text = normalizeName(name, { strict: false });
30
+ return text.replace(/\s+/g, '-');
31
+ }
32
+
33
+ function normalizeName(name, options = {}) {
34
+ const text = typeof name === 'string' ? name.trim() : '';
35
+ if (text === '') {
36
+ if (options.strict === false) return DEFAULT_ASSISTANT_NAME;
37
+ throw pactiveError('ASSISTANT_NAME_REQUIRED', 'an assistant needs a name');
38
+ }
39
+ if (text.includes('#')) {
40
+ throw pactiveError('ASSISTANT_NAME_INVALID', `an assistant name must not contain "#": ${text}`);
41
+ }
42
+ return text;
43
+ }
44
+
45
+ /** Only the keys the user actually set — an absent key falls back to the global policy. */
46
+ function normalizePolicy(raw) {
47
+ const out = {};
48
+ if (!raw || typeof raw !== 'object') return out;
49
+ for (const key of POLICY_KEYS) {
50
+ if (raw[key] !== undefined && raw[key] !== null) out[key] = raw[key];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * The herdr binding of one assistant (contract §2.1): `{paneId, agentName, host, surface, tabId, boundAt}`.
57
+ * Anything without a usable `paneId` is "not bound" — a half-written record must not look bound.
58
+ */
59
+ export function normalizePane(raw) {
60
+ if (!raw || typeof raw !== 'object') return null;
61
+ const paneId = typeof raw.paneId === 'string' && raw.paneId !== '' ? raw.paneId : null;
62
+ if (!paneId) return null;
63
+ return {
64
+ paneId,
65
+ agentName: typeof raw.agentName === 'string' && raw.agentName !== '' ? raw.agentName : null,
66
+ // No host name is invented here: the adapter records which host the pane runs (I6 keeps
67
+ // src/core host-agnostic), so an unset host stays `null` instead of defaulting to one.
68
+ host: typeof raw.host === 'string' && raw.host !== '' ? raw.host : null,
69
+ surface: raw.surface === 'pane' ? 'pane' : raw.surface === 'agent' ? 'agent' : (raw.agentName ? 'agent' : 'pane'),
70
+ tabId: typeof raw.tabId === 'string' && raw.tabId !== '' ? raw.tabId : null,
71
+ boundAt: Number.isFinite(raw.boundAt) ? raw.boundAt : 0,
72
+ };
73
+ }
74
+
75
+ export function makeAssistant(spec = {}, ownerKey = '', now = Date.now()) {
76
+ const name = normalizeName(spec.name);
77
+ const assistantId = typeof spec.assistantId === 'string' && spec.assistantId
78
+ ? spec.assistantId
79
+ : assistantIdFor(name, ownerKey);
80
+ const scope = spec.scope && typeof spec.scope === 'object' ? spec.scope : {};
81
+ const delivery = spec.delivery && typeof spec.delivery === 'object' ? spec.delivery : {};
82
+ return {
83
+ assistantId,
84
+ name,
85
+ title: typeof spec.title === 'string' && spec.title.trim() !== '' ? spec.title.trim() : name,
86
+ role: typeof spec.role === 'string' ? spec.role : '',
87
+ scope: {
88
+ projectHash: typeof scope.projectHash === 'string' && scope.projectHash ? scope.projectHash : null,
89
+ globs: Array.isArray(scope.globs) ? scope.globs.filter((g) => typeof g === 'string' && g !== '') : [],
90
+ },
91
+ enabled: spec.enabled === true,
92
+ policy: normalizePolicy(spec.policy),
93
+ delivery: {
94
+ host: typeof delivery.host === 'string' && delivery.host ? delivery.host : null,
95
+ sessionId: typeof delivery.sessionId === 'string' && delivery.sessionId ? delivery.sessionId : null,
96
+ fallback: typeof delivery.fallback === 'string' && delivery.fallback ? delivery.fallback : 'outbox',
97
+ },
98
+ // I75/I76: the display instance (herdr pane), never the identity. `null` = not bound yet.
99
+ pane: normalizePane(spec.pane),
100
+ createdAt: Number.isFinite(spec.createdAt) ? spec.createdAt : now,
101
+ lastFiredAt: Number.isFinite(spec.lastFiredAt) ? spec.lastFiredAt : 0,
102
+ firedCount: Number.isFinite(spec.firedCount) ? spec.firedCount : 0,
103
+ };
104
+ }
105
+
106
+ /** Tolerant read-side normalisation: a dirty record must not crash the state load. */
107
+ export function normalizeAssistant(raw, ownerKey = '', now = Date.now()) {
108
+ if (!raw || typeof raw !== 'object') return null;
109
+ const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name : DEFAULT_ASSISTANT_NAME;
110
+ const assistantId = typeof raw.assistantId === 'string' && raw.assistantId ? raw.assistantId : assistantIdFor(name, ownerKey);
111
+ return makeAssistant({ ...raw, name, assistantId }, ownerKey, now);
112
+ }
113
+
114
+ export function normalizeAssistantTable(raw, ownerKey = '', now = Date.now()) {
115
+ const out = {};
116
+ if (!raw || typeof raw !== 'object') return out;
117
+ for (const [key, value] of Object.entries(raw)) {
118
+ const record = normalizeAssistant(value, ownerKey, now);
119
+ if (!record) continue;
120
+ out[key] = record;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ function tableOf(state, options = {}) {
126
+ if (!state.assistants || typeof state.assistants !== 'object') {
127
+ if (options.init !== true) return {};
128
+ state.assistants = {};
129
+ }
130
+ return state.assistants;
131
+ }
132
+
133
+ function ownerKeyOf(state) {
134
+ return typeof state?.identity?.ownerKey === 'string' ? state.identity.ownerKey : '';
135
+ }
136
+
137
+ function byName(a, b) {
138
+ return a.name.localeCompare(b.name);
139
+ }
140
+
141
+ export const assistants = {
142
+ /** Add one assistant. A duplicate name/id is an explicit error, never a silent overwrite. */
143
+ add(state, spec = {}, now = Date.now()) {
144
+ const table = tableOf(state, { init: true });
145
+ const name = normalizeName(spec.name);
146
+ const record = makeAssistant({ ...spec, name }, ownerKeyOf(state), now);
147
+ if (table[record.assistantId]) {
148
+ throw pactiveError('ASSISTANT_EXISTS', `assistant already exists: ${record.assistantId}`);
149
+ }
150
+ const clash = Object.values(table).find((a) => a && a.name === name);
151
+ if (clash) throw pactiveError('ASSISTANT_NAME_TAKEN', `assistant name already taken: ${name}`);
152
+ table[record.assistantId] = record;
153
+ return record;
154
+ },
155
+
156
+ list(state) {
157
+ return Object.values(tableOf(state)).filter((a) => a && typeof a === 'object').sort(byName);
158
+ },
159
+
160
+ /** Resolve by exact id, then by name. Unknown → null (the caller decides the exit code). */
161
+ resolve(state, ref) {
162
+ const text = typeof ref === 'string' ? ref.trim() : '';
163
+ if (text === '') return null;
164
+ const table = tableOf(state);
165
+ if (table[text]) return table[text];
166
+ return Object.values(table).find((a) => a && a.name === text) ?? null;
167
+ },
168
+
169
+ get(state, ref) {
170
+ return assistants.resolve(state, ref);
171
+ },
172
+
173
+ setEnabled(state, ref, enabled) {
174
+ const record = assistants.resolve(state, ref);
175
+ if (!record) return null;
176
+ record.enabled = enabled === true;
177
+ return record;
178
+ },
179
+
180
+ /** Bind one assistant to one herdr pane (I79: the caller replaces, never adds a second pane). */
181
+ bindPane(state, ref, binding, now = Date.now()) {
182
+ const record = assistants.resolve(state, ref);
183
+ if (!record) return null;
184
+ const pane = normalizePane({ ...binding, boundAt: Number.isFinite(binding?.boundAt) ? binding.boundAt : now });
185
+ if (!pane) return null;
186
+ record.pane = pane;
187
+ return record;
188
+ },
189
+
190
+ /** Drop the binding only — the assistant, its tasks and its ledger history stay (I75). */
191
+ unbindPane(state, ref) {
192
+ const record = assistants.resolve(state, ref);
193
+ if (!record) return null;
194
+ record.pane = null;
195
+ return record;
196
+ },
197
+
198
+ /** Remove the assistant AND its tasks (I65: no orphans). The ledger keeps the history. */
199
+ remove(state, ref) {
200
+ const table = tableOf(state, { init: true });
201
+ const record = assistants.resolve(state, ref);
202
+ if (!record) return null;
203
+ const id = record.assistantId;
204
+ delete table[id];
205
+ if (state.guardByAssistant && typeof state.guardByAssistant === 'object') delete state.guardByAssistant[id];
206
+ const before = Array.isArray(state.tasks) ? state.tasks.length : 0;
207
+ state.tasks = (Array.isArray(state.tasks) ? state.tasks : []).filter((t) => t && t.assistantId !== id);
208
+ return { removed: record, tasks: before - state.tasks.length };
209
+ },
210
+
211
+ /** Rename keeps the id: state, ledger history and tasks stay bound to the same assistant. */
212
+ rename(state, ref, newName) {
213
+ const table = tableOf(state, { init: true });
214
+ const record = assistants.resolve(state, ref);
215
+ if (!record) return null;
216
+ const name = normalizeName(newName);
217
+ const clash = Object.values(table).find((a) => a && a.assistantId !== record.assistantId && a.name === name);
218
+ if (clash) throw pactiveError('ASSISTANT_NAME_TAKEN', `assistant name already taken: ${name}`);
219
+ record.name = name;
220
+ if (!record.title || record.title === ref) record.title = name;
221
+ return record;
222
+ },
223
+
224
+ /** The implicit `default` assistant, created on first use (never enabled silently). */
225
+ ensureDefault(state, now = Date.now()) {
226
+ const existing = assistants.resolve(state, DEFAULT_ASSISTANT_NAME);
227
+ if (existing) return existing;
228
+ const table = tableOf(state, { init: true });
229
+ const record = makeAssistant({ name: DEFAULT_ASSISTANT_NAME }, ownerKeyOf(state), now);
230
+ table[record.assistantId] = record;
231
+ return record;
232
+ },
233
+
234
+ defaultId(state) {
235
+ return defaultAssistantId(ownerKeyOf(state));
236
+ },
237
+ };