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/bin/pactive.mjs CHANGED
@@ -26,6 +26,8 @@ function usage(out) {
26
26
  「10 分钟后提醒我喝水」 → 登记定时任务
27
27
  「主动提醒开启了吗?」 → 看开关状态
28
28
  「帮我开启主动提醒」 → 给出可复制命令(人工确认后才生效)
29
+ 「再给我加一个盯盘的助理」 → 给出可复制命令(人工确认后才生效)
30
+ 「我有几个助理?各自开了吗?」 → 列出助理、开关、下次到期
29
31
 
30
32
  命令(高级/运维):
31
33
  pactive <host> [args...] run a host with pactive injected (stdio inherited)
@@ -33,8 +35,18 @@ function usage(out) {
33
35
  pactive doctor [--json] diagnose hosts, state root and permissions (read-only)
34
36
  pactive config get [<key>] print current values and defaults (read-only)
35
37
  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 schedule "<prompt>" --when <when> [--assistant <name|id>] register a timed task
39
+ pactive tasks [--assistant <name|id>] [--json] list scheduled tasks (id / kind / due / owner)
40
+ pactive tick [--assistant <name|id>] [--ensure-fleet] [--json] one round → ledger(--ensure-fleet 先补 pane)
41
+ pactive fleet up [--assistant <name|id>] [--all] [--host pi|opcteam|dsh] [--tab <label>] [--dry-run] [--json]
42
+ pactive fleet ls [--json] 助理 ↔ herdr pane_id ↔ agent 名 ↔ 状态 ↔ 最近投递
43
+ pactive fleet down [--assistant <name|id>] [--all] 只关 pane,不动助理(I75)
44
+ pactive fleet open --assistant <name|id> 焦点到该助理的 pane
45
+ pactive assistant add <name> [--title …] [--role …] [--scope <glob>] --confirm
46
+ pactive assistant ls|show <name|id> list assistants / show one (read-only)
47
+ pactive assistant enable|disable <name|id> --confirm
48
+ pactive assistant rename <name|id> <newName> --confirm
49
+ pactive assistant rm <name|id> --confirm remove an assistant and its tasks
38
50
  pactive cancel <taskId> cancel one task
39
51
  pactive install <host> register pactive with a host (idempotent)
40
52
  pactive uninstall <host> unregister pactive and remove pactive state residue
@@ -49,6 +61,7 @@ Environment:
49
61
  PACTIVE_PI_BIN override the pi executable
50
62
  PACTIVE_OPCTEAM_BIN override the opcteam executable
51
63
  PACTIVE_DSH_BIN override the dsh executable
64
+ PACTIVE_HERDR_BIN override the herdr executable(fleet 复用 herdr;无 herdr 时 fleet 明确拒绝)
52
65
  `);
53
66
  }
54
67
 
@@ -299,6 +312,16 @@ function emit(io, asJson, payload, humanLines) {
299
312
  return 0;
300
313
  }
301
314
 
315
+ /**
316
+ * Listings (`assistant ls` / `show`) are the answer to a query, not diagnostics: they go to
317
+ * stdout so `pactive assistant ls | wc -l` counts assistants (same reasoning as `--help`).
318
+ */
319
+ function emitListing(io, asJson, payload, lines) {
320
+ if (asJson) io.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
321
+ else for (const line of lines) io.stdout.write(`${line}\n`);
322
+ return 0;
323
+ }
324
+
302
325
  function configKeys(core) {
303
326
  const described = core.config.describe();
304
327
  return { described, keys: Object.keys(described.defaults) };
@@ -326,11 +349,13 @@ function coerceValue(spec, raw) {
326
349
 
327
350
  async function configGetCmd(args, env, io) {
328
351
  const asJson = args.includes('--json');
329
- const keys = args.filter((a) => a !== '--json');
352
+ // `--assistant <name|id>` reads that assistant's override first, then falls back to global (I63).
353
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json'), '--assistant');
354
+ const keys = assistantOpt.args;
330
355
  const core = await loadCore();
331
356
  const { described, keys: known } = configKeys(core);
332
357
  if (keys.length > 1) {
333
- io.stderr.write('pactive: usage: pactive config get [<key>] [--json]\n');
358
+ io.stderr.write('pactive: usage: pactive config get [<key>] [--assistant <name|id>] [--json]\n');
334
359
  return 2;
335
360
  }
336
361
  const key = keys[0];
@@ -339,10 +364,16 @@ async function configGetCmd(args, env, io) {
339
364
  return 2;
340
365
  }
341
366
  const state = core.loadState(core.resolvePaths(HOST_KIND, env));
367
+ let assistantId = '';
368
+ if (assistantOpt.value !== null) {
369
+ const record = core.assistants.resolve(state, assistantOpt.value);
370
+ if (!record) return unknownAssistant(core, state, assistantOpt.value, io);
371
+ assistantId = record.assistantId;
372
+ }
342
373
  const targets = key === undefined ? known : [key];
343
374
  const values = targets.map((k) => ({
344
375
  key: k,
345
- value: core.config.get(state, k),
376
+ value: core.config.get(state, k, assistantId),
346
377
  default: described.defaults[k],
347
378
  humanGated: described.schema[k].humanGated === true,
348
379
  }));
@@ -360,14 +391,15 @@ async function configGetCmd(args, env, io) {
360
391
  async function configSetCmd(args, env, io) {
361
392
  const asJson = args.includes('--json');
362
393
  const confirm = args.includes('--confirm');
363
- const rest = args.filter((a) => a !== '--json' && a !== '--confirm');
394
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--assistant');
395
+ const rest = assistantOpt.args;
364
396
  const [key, raw] = rest;
365
397
  const core = await loadCore();
366
398
  const { described, keys: known } = configKeys(core);
367
399
 
368
400
  // Validity first (a usage error must not read as "gate refused"), then the gate.
369
401
  if (key === undefined || raw === undefined || rest.length > 2) {
370
- io.stderr.write('pactive: usage: pactive config set <key> <value> --confirm\n');
402
+ io.stderr.write('pactive: usage: pactive config set <key> <value> [--assistant <name|id>] --confirm\n');
371
403
  return 2;
372
404
  }
373
405
  if (!known.includes(key)) {
@@ -386,27 +418,603 @@ async function configSetCmd(args, env, io) {
386
418
  }
387
419
 
388
420
  const paths = core.resolvePaths(HOST_KIND, env);
421
+ let assistantId = '';
422
+ if (assistantOpt.value !== null) {
423
+ const overridable = described.assistantKeys ?? [];
424
+ if (!overridable.includes(key)) {
425
+ io.stderr.write(`pactive: ${key} cannot be overridden per assistant\n`);
426
+ io.stderr.write(`pactive: per-assistant keys: ${overridable.join(', ')}\n`);
427
+ return 2;
428
+ }
429
+ const record = core.assistants.resolve(core.loadState(paths), assistantOpt.value);
430
+ if (!record) return unknownAssistant(core, core.loadState(paths), assistantOpt.value, io);
431
+ assistantId = record.assistantId;
432
+ }
433
+
389
434
  let applied = null;
390
435
  await core.mutateState(paths, (state) => {
391
- applied = core.config.setHumanGated(state, key, coerced.value, { confirm: true });
436
+ applied = core.config.setHumanGated(state, key, coerced.value, { confirm: true, assistantId });
392
437
  if (!applied.ok) throw new PactiveError(`config refused: ${applied.reason}`, { code: 'CONFIG_REFUSED', exitCode: 1 });
393
438
  return state;
394
439
  });
395
440
  return emit(
396
441
  io,
397
442
  asJson,
398
- { ok: true, key, value: coerced.value, stateFile: paths.stateFile },
399
- [`${key} = ${JSON.stringify(coerced.value)}`, `pactive: wrote ${paths.stateFile}`],
443
+ { ok: true, key, value: coerced.value, assistantId: assistantId || null, stateFile: paths.stateFile },
444
+ [
445
+ `${key} = ${JSON.stringify(coerced.value)}${assistantId ? ` (assistant ${assistantId})` : ''}`,
446
+ `pactive: wrote ${paths.stateFile}`,
447
+ ],
448
+ );
449
+ }
450
+
451
+ // ─────────────────── 助理舰队(契约 §3 / I61–I66) ───────────────────
452
+ //
453
+ // 写操作一律 --confirm:缺了就 exit 2、零写盘、stdout 干净(与 config set 同一道人工闸门)。
454
+ // 解析助理(名字或 id)在写之前完成:未知名直接 exit 2 并列出已有助理,绝不新建一个同名幽灵。
455
+
456
+ const ASSISTANT_SUBS = ['add', 'ls', 'show', 'enable', 'disable', 'rm', 'rename'];
457
+ const ASSISTANT_WRITE_SUBS = new Set(['add', 'enable', 'disable', 'rm', 'rename']);
458
+ const ASSISTANT_USAGE = 'pactive: usage: pactive assistant add|ls|show|enable|disable|rm|rename ...';
459
+
460
+ function unknownAssistant(core, state, ref, io) {
461
+ const names = core.assistants.list(state).map((a) => a.name);
462
+ io.stderr.write(`pactive: unknown assistant '${ref}'\n`);
463
+ io.stderr.write(
464
+ `pactive: known assistants: ${names.length > 0 ? names.join(', ') : '(none yet)'}\n`,
465
+ );
466
+ if (names.length === 0) io.stderr.write('pactive: create one with: pactive assistant add <name> --confirm\n');
467
+ return 2;
468
+ }
469
+
470
+ function nextDueAt(state, assistantId) {
471
+ const times = [];
472
+ for (const task of state.tasks ?? []) {
473
+ if (!task || task.active === false || task.assistantId !== assistantId) continue;
474
+ if (Number.isFinite(task.dueAt)) times.push(task.dueAt);
475
+ }
476
+ return times.length > 0 ? Math.min(...times) : null;
477
+ }
478
+
479
+ function assistantRow(core, state, record) {
480
+ const tasks = core.schedule.list(state, record.assistantId).filter((t) => t.active !== false);
481
+ return {
482
+ assistantId: record.assistantId,
483
+ name: record.name,
484
+ title: record.title,
485
+ enabled: record.enabled === true,
486
+ tasks: tasks.length,
487
+ firedCount: Number.isFinite(record.firedCount) ? record.firedCount : 0,
488
+ lastFiredAt: Number.isFinite(record.lastFiredAt) ? record.lastFiredAt : 0,
489
+ nextDueAt: nextDueAt(state, record.assistantId),
490
+ policy: record.policy ?? {},
491
+ };
492
+ }
493
+
494
+ function humanAssistant(row) {
495
+ const next = row.nextDueAt ? new Date(row.nextDueAt).toISOString() : '-';
496
+ return `${row.name} ${row.assistantId} enabled=${row.enabled} tasks=${row.tasks} fires=${row.firedCount} next=${next}`;
497
+ }
498
+
499
+ async function assistantCmd(args, env, io) {
500
+ const asJson = args.includes('--json');
501
+ const confirm = args.includes('--confirm');
502
+ const [sub, ...tail] = args;
503
+
504
+ if (sub === undefined || !ASSISTANT_SUBS.includes(sub)) {
505
+ io.stderr.write(`${ASSISTANT_USAGE}\n`);
506
+ return 2;
507
+ }
508
+ // The gate is checked before anything touches the filesystem (same order as config set).
509
+ if (ASSISTANT_WRITE_SUBS.has(sub) && !confirm) {
510
+ io.stderr.write(`${CONFIRM_REFUSAL}\n`);
511
+ io.stderr.write(`pactive: re-run with --confirm: pactive assistant ${sub} ${tail.join(' ')} --confirm\n`);
512
+ return 2;
513
+ }
514
+
515
+ const core = await loadCore();
516
+ const paths = core.resolvePaths(HOST_KIND, env);
517
+ const state = core.loadState(paths);
518
+
519
+ if (sub === 'ls') {
520
+ const rows = core.assistants.list(state).map((a) => assistantRow(core, state, a));
521
+ return emitListing(
522
+ io,
523
+ asJson,
524
+ { count: rows.length, assistants: rows },
525
+ rows.length === 0 ? ['pactive: no assistants yet — add one with: pactive assistant add <name> --confirm'] : rows.map(humanAssistant),
526
+ );
527
+ }
528
+
529
+ if (sub === 'show') {
530
+ const ref = tail[0];
531
+ if (ref === undefined) {
532
+ io.stderr.write('pactive: usage: pactive assistant show <name|id>\n');
533
+ return 2;
534
+ }
535
+ const record = core.assistants.resolve(state, ref);
536
+ if (!record) return unknownAssistant(core, state, ref, io);
537
+ const row = assistantRow(core, state, record);
538
+ const keys = core.config.describe().assistantKeys ?? [];
539
+ const policy = Object.fromEntries(keys.map((k) => [k, core.config.get(state, k, record.assistantId)]));
540
+ return emitListing(
541
+ io,
542
+ asJson,
543
+ { ...row, role: record.role, scope: record.scope, delivery: record.delivery, resolvedPolicy: policy },
544
+ [
545
+ humanAssistant(row),
546
+ `title: ${record.title}`,
547
+ `role: ${record.role || '-'}`,
548
+ `scope: ${record.scope?.globs?.length ? record.scope.globs.join(', ') : '(whole project)'}`,
549
+ `policy: ${Object.entries(policy).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')}`,
550
+ ],
551
+ );
552
+ }
553
+
554
+ if (sub === 'add') {
555
+ const titleOpt = takeOption(tail, '--title');
556
+ const roleOpt = takeOption(titleOpt.args, '--role');
557
+ const scopeOpt = takeOption(roleOpt.args, '--scope');
558
+ const name = scopeOpt.args[0];
559
+ if (name === undefined) {
560
+ io.stderr.write('pactive: usage: pactive assistant add <name> [--title "…"] [--role "…"] [--scope <glob>] --confirm\n');
561
+ return 2;
562
+ }
563
+ if (core.assistants.resolve(state, name)) {
564
+ io.stderr.write(`pactive: assistant '${name}' already exists\n`);
565
+ return 2;
566
+ }
567
+ let created;
568
+ await core.mutateState(paths, (current) => {
569
+ created = core.assistants.add(current, {
570
+ name,
571
+ title: titleOpt.value ?? undefined,
572
+ role: roleOpt.value ?? undefined,
573
+ scope: scopeOpt.value ? { globs: [scopeOpt.value] } : undefined,
574
+ }, Date.now());
575
+ return current;
576
+ });
577
+ return emit(
578
+ io,
579
+ asJson,
580
+ { ok: true, confirmed: confirm, ...assistantRow(core, state, created) },
581
+ [humanAssistant(assistantRow(core, state, created)), 'pactive: 新建助理默认是关的(装上不等于打扰)—— 要开:pactive assistant enable ' + created.name + ' --confirm'],
582
+ );
583
+ }
584
+
585
+ // Remaining subcommands all name one existing assistant.
586
+ const ref = tail[0];
587
+ if (ref === undefined) {
588
+ io.stderr.write(`pactive: usage: pactive assistant ${sub} <name|id>${sub === 'rename' ? ' <newName>' : ''} --confirm\n`);
589
+ return 2;
590
+ }
591
+ const record = core.assistants.resolve(state, ref);
592
+ if (!record) return unknownAssistant(core, state, ref, io);
593
+
594
+ if (sub === 'rename') {
595
+ const newName = tail[1];
596
+ if (newName === undefined) {
597
+ io.stderr.write('pactive: usage: pactive assistant rename <name|id> <newName> --confirm\n');
598
+ return 2;
599
+ }
600
+ let renamed;
601
+ await core.mutateState(paths, (current) => {
602
+ renamed = core.assistants.rename(current, record.assistantId, newName);
603
+ return current;
604
+ });
605
+ // The record could have been removed between the read and the lock: say so, do not crash.
606
+ if (!renamed) return unknownAssistant(core, state, ref, io);
607
+ return emit(
608
+ io,
609
+ asJson,
610
+ { ok: true, confirmed: confirm, ...assistantRow(core, state, renamed) },
611
+ [`pactive: renamed ${record.name} → ${renamed.name}`, `id 不变:${renamed.assistantId}`],
612
+ );
613
+ }
614
+
615
+ if (sub === 'enable' || sub === 'disable') {
616
+ let updated;
617
+ await core.mutateState(paths, (current) => {
618
+ updated = core.assistants.setEnabled(current, record.assistantId, sub === 'enable');
619
+ return current;
620
+ });
621
+ if (!updated) return unknownAssistant(core, state, ref, io);
622
+ return emit(
623
+ io,
624
+ asJson,
625
+ { ok: true, confirmed: confirm, ...assistantRow(core, state, updated) },
626
+ [`pactive: ${updated.name} enabled=${updated.enabled}`],
627
+ );
628
+ }
629
+
630
+ // rm: the assistant AND its tasks go away together — no orphan tasks (I65), and no orphan
631
+ // pane either: an assistant's pane is closed first (best effort — herdr may be absent).
632
+ let paneClosed = null;
633
+ if (record.pane) {
634
+ const herdr = await loadHerdr();
635
+ const closed = herdr.close(record.pane.paneId, { env });
636
+ paneClosed = closed.ok ? closed.state : `skipped:${closed.reason}`;
637
+ }
638
+ let removed;
639
+ await core.mutateState(paths, (current) => {
640
+ removed = core.assistants.remove(current, record.assistantId);
641
+ return current;
642
+ });
643
+ if (!removed) return unknownAssistant(core, state, ref, io);
644
+ return emit(
645
+ io,
646
+ asJson,
647
+ { ok: true, confirmed: confirm, removed: removed.removed.name, assistantId: record.assistantId, tasksRemoved: removed.tasks, paneClosed },
648
+ [
649
+ `pactive: removed ${removed.removed.name} (${record.assistantId})`,
650
+ `pactive: ${removed.tasks} task(s) removed with it; the ledger keeps the history`,
651
+ ...(paneClosed === null ? [] : [`pactive: pane ${record.pane.paneId} → ${paneClosed}`]),
652
+ ],
653
+ );
654
+ }
655
+
656
+ // ─────────────────── 助理舰队 ↔ herdr pane(契约 §2.1 / I74–I79) ───────────────────
657
+ //
658
+ // pactive 只**复用** herdr:这里不建 PTY、不画 UI,只组装 `herdr` 的 argv 并读回它的 JSON(I74)。
659
+ // 三条纪律:① 无 herdr → 明确拒绝 + 替代路径(I78)② 投递成功只认 agent 面(I77)
660
+ // ③ 一个助理至多一个活 pane,`fleet up` 幂等(I79)。
661
+
662
+ const FLEET_SUBS = ['up', 'ls', 'down', 'open'];
663
+ const FLEET_USAGE = 'pactive: usage: pactive fleet up|ls|down|open [--assistant <name|id>] [--all] [--host <h>] [--tab <label>] [--dry-run] [--json]';
664
+ const IDLE_PROMPT = '(pactive 空闲触发:请汇报当前进展与待办)';
665
+
666
+ async function loadHerdr() {
667
+ return import(new URL('../src/hosts/herdr/index.mjs', import.meta.url));
668
+ }
669
+
670
+ function fleetRefused(io, probe, action) {
671
+ io.stderr.write(`pactive: fleet ${action} 拒绝执行 —— herdr 不可用\n`);
672
+ for (const r of probe.reasons) io.stderr.write(`pactive: ${r.code}: ${r.reason}\n`);
673
+ io.stderr.write(`pactive: ${probe.hint}\n`);
674
+ io.stderr.write('pactive: 替代路径:pactive schedule "<prompt>" --when 10m(outbox)+ pactive tick(补投)\n');
675
+ return probe.reasons.some((r) => r.code === 'HOST_MISSING') ? 127 : 1;
676
+ }
677
+
678
+ /** `--assistant <ref>` or `--all`; both absent is a usage error (never "guess one"). */
679
+ function fleetTargets(core, state, options) {
680
+ if (options.all) return core.assistants.list(state);
681
+ if (options.ref === null) return null;
682
+ const record = core.assistants.resolve(state, options.ref);
683
+ return record ? [record] : [];
684
+ }
685
+
686
+ function takeFleetOptions(args, flags = []) {
687
+ const bare = args.filter((a) => !flags.includes(a));
688
+ const a = takeOption(bare, '--assistant');
689
+ const h = takeOption(a.args, '--host');
690
+ const t = takeOption(h.args, '--tab');
691
+ return { ref: a.value, host: h.value, tab: t.value, extra: t.args };
692
+ }
693
+
694
+ function humanFleetRow(row) {
695
+ const status = row.status === null || row.status === undefined ? 'unobserved' : row.status;
696
+ const last = row.lastDeliveryAt ? `${row.lastDeliveryEvent}@${new Date(row.lastDeliveryAt).toISOString()}` : '-';
697
+ return `${row.name} ${row.assistantId} pane=${row.paneId ?? '-'} agent=${row.agentName ?? '-'} host=${row.host ?? '-'} status=${status} last=${last}`;
698
+ }
699
+
700
+ async function fleetUpCmd(args, env, io) {
701
+ const asJson = args.includes('--json');
702
+ const dryRun = args.includes('--dry-run');
703
+ const all = args.includes('--all');
704
+ const options = takeFleetOptions(args, ['--json', '--dry-run', '--all']);
705
+ if (options.extra.length > 0 || (options.ref === null && !all)) {
706
+ io.stderr.write('pactive: usage: pactive fleet up [--assistant <name|id>] [--all] [--host pi|opcteam|dsh] [--tab <label>] [--dry-run] [--json]\n');
707
+ return 2;
708
+ }
709
+ const core = await loadCore();
710
+ const herdr = await loadHerdr();
711
+ const paths = core.resolvePaths(HOST_KIND, env);
712
+ const state = core.loadState(paths);
713
+ const targets = fleetTargets(core, state, { ref: options.ref, all });
714
+ if (targets === null) return 2;
715
+ if (targets.length === 0) return unknownAssistant(core, state, options.ref, io);
716
+
717
+ // I78: the gate runs before any herdr call *and* before any state write.
718
+ const probe = herdr.available({ env });
719
+ if (!probe.ok) return fleetRefused(io, probe, 'up');
720
+
721
+ if (options.host !== null && !HOSTS.includes(options.host)) {
722
+ io.stderr.write(`pactive: unknown host '${options.host}'\npactive: known hosts: ${HOSTS.join(', ')}\n`);
723
+ return 2;
724
+ }
725
+ const cwd = path.resolve(env.PACTIVE_FLEET_CWD ?? process.cwd());
726
+ const ctx = { env };
727
+
728
+ if (dryRun) {
729
+ const plans = targets.map((target) => {
730
+ const host = options.host ?? target.delivery?.host ?? 'pi';
731
+ const plan = herdr.planSpawn({ assistantId: target.assistantId, host, tab: options.tab, cwd });
732
+ return {
733
+ assistantId: target.assistantId,
734
+ name: target.name,
735
+ host,
736
+ surface: plan.surface,
737
+ agentName: plan.agentName,
738
+ // `text` is the exact shell line, so a judge can grep the JSON for "pane split".
739
+ commands: plan.steps.map((step) => {
740
+ const argv = [probe.exec, ...step.argv];
741
+ return { argv, text: argv.join(' ') };
742
+ }),
743
+ };
744
+ });
745
+ return emit(
746
+ io,
747
+ asJson,
748
+ { ok: true, dryRun: true, executed: false, plans },
749
+ plans.flatMap((p) => [
750
+ `pactive: [dry-run] ${p.name} → host=${p.host} surface=${p.surface} agent=${p.agentName}`,
751
+ ...p.commands.map((c) => ` + ${c.text}`),
752
+ ]),
753
+ );
754
+ }
755
+
756
+ const results = [];
757
+ for (const target of targets) {
758
+ const host = options.host ?? target.delivery?.host ?? 'pi';
759
+ const existing = state.assistants?.[target.assistantId]?.pane ?? target.pane ?? null;
760
+ // I79: an already-bound assistant with a live pane is reused, never double-opened.
761
+ if (existing && existing.paneId) {
762
+ const alive = herdr.paneAlive(existing.paneId, ctx);
763
+ if (alive.alive === true) {
764
+ results.push({ assistantId: target.assistantId, name: target.name, host, state: 'reused', ...existing });
765
+ continue;
766
+ }
767
+ // Dead pane → drop the stale binding first (a rebuild must not look like a second pane).
768
+ await core.mutateState(paths, (current) => {
769
+ core.assistants.unbindPane(current, target.assistantId);
770
+ return current;
771
+ });
772
+ }
773
+ const spawned = herdr.spawn({ assistantId: target.assistantId, host, tab: options.tab, cwd }, ctx);
774
+ if (!spawned.ok) {
775
+ results.push({ assistantId: target.assistantId, name: target.name, host, state: spawned.state, reason: spawned.reason });
776
+ continue;
777
+ }
778
+ await core.mutateState(paths, (current) => {
779
+ core.assistants.bindPane(current, target.assistantId, spawned.binding);
780
+ return current;
781
+ });
782
+ results.push({ assistantId: target.assistantId, name: target.name, host, state: spawned.state ?? 'bound', ...spawned.binding });
783
+ }
784
+
785
+ const ok = results.every((r) => r.state === 'bound' || r.state === 'reused' || r.state === 'adopted');
786
+ emit(
787
+ io,
788
+ asJson,
789
+ { ok, executed: true, bound: results.filter((r) => r.state === 'bound').length, reused: results.filter((r) => r.state === 'reused').length, adopted: results.filter((r) => r.state === 'adopted').length, results },
790
+ results.map(
791
+ (r) => (r.state === 'reused'
792
+ ? `pactive: reused ${r.name} → pane ${r.paneId} (agent ${r.agentName ?? '-'}, host ${r.host})`
793
+ : r.state === 'bound' || r.state === 'adopted'
794
+ ? `pactive: ${r.state} ${r.name} → pane ${r.paneId} (agent ${r.agentName ?? '-'}, surface ${r.surface}, host ${r.host})`
795
+ : `pactive: FAILED ${r.name} — ${r.state}: ${r.reason}`),
796
+ ),
797
+ );
798
+ return ok ? 0 : 1;
799
+ }
800
+
801
+ async function fleetLsCmd(args, env, io) {
802
+ const asJson = args.includes('--json');
803
+ const core = await loadCore();
804
+ const { fleetPayload } = await import(new URL('../src/hosts/herdr/fleet-view.mjs', import.meta.url));
805
+ const paths = core.resolvePaths(HOST_KIND, env);
806
+ const state = core.loadState(paths);
807
+ const payload = fleetPayload(core, state, paths, env);
808
+ if (!asJson) {
809
+ if (!payload.herdr.available) {
810
+ io.stderr.write(`pactive: herdr 不可用(${payload.herdr.reason})—— status 标为 unobserved,绑定关系仍来自 state\n`);
811
+ } else if (payload.herdr.reason) {
812
+ io.stderr.write(`pactive: herdr agent list failed: ${payload.herdr.reason}\n`);
813
+ }
814
+ }
815
+ return emitListing(
816
+ io,
817
+ asJson,
818
+ payload,
819
+ payload.rows.length === 0 ? ['pactive: no assistants yet'] : payload.rows.map(humanFleetRow),
820
+ );
821
+ }
822
+
823
+ async function fleetDownCmd(args, env, io) {
824
+ const asJson = args.includes('--json');
825
+ const all = args.includes('--all');
826
+ const options = takeFleetOptions(args, ['--json', '--all']);
827
+ if (options.extra.length > 0 || (options.ref === null && !all)) {
828
+ io.stderr.write('pactive: usage: pactive fleet down [--assistant <name|id>] [--all] [--json]\n');
829
+ return 2;
830
+ }
831
+ const core = await loadCore();
832
+ const herdr = await loadHerdr();
833
+ const paths = core.resolvePaths(HOST_KIND, env);
834
+ const state = core.loadState(paths);
835
+ const targets = fleetTargets(core, state, { ref: options.ref, all });
836
+ if (targets === null) return 2;
837
+ if (targets.length === 0) return unknownAssistant(core, state, options.ref, io);
838
+ const probe = herdr.available({ env });
839
+ if (!probe.ok) return fleetRefused(io, probe, 'down');
840
+
841
+ const results = [];
842
+ for (const target of targets) {
843
+ const pane = target.pane ?? null;
844
+ if (!pane) {
845
+ results.push({ assistantId: target.assistantId, name: target.name, state: 'not-bound' });
846
+ continue;
847
+ }
848
+ const closed = herdr.close(pane.paneId, { env });
849
+ if (closed.ok) {
850
+ // I75: only the binding goes — the assistant, its tasks and its history stay.
851
+ await core.mutateState(paths, (current) => {
852
+ core.assistants.unbindPane(current, target.assistantId);
853
+ return current;
854
+ });
855
+ }
856
+ results.push({ assistantId: target.assistantId, name: target.name, paneId: pane.paneId, state: closed.state, reason: closed.reason ?? null });
857
+ }
858
+ const ok = results.every((r) => r.state === 'closed' || r.state === 'already-closed' || r.state === 'not-bound');
859
+ emit(
860
+ io,
861
+ asJson,
862
+ { ok, results },
863
+ results.map((r) => (r.state === 'not-bound'
864
+ ? `pactive: ${r.name} 没有绑定 pane(助理仍在)`
865
+ : r.state === 'closed' || r.state === 'already-closed'
866
+ ? `pactive: closed ${r.name} pane ${r.paneId}(助理仍在:pactive assistant ls)`
867
+ : `pactive: FAILED ${r.name} — ${r.state}: ${r.reason}`)),
868
+ );
869
+ return ok ? 0 : 1;
870
+ }
871
+
872
+ async function fleetOpenCmd(args, env, io) {
873
+ const asJson = args.includes('--json');
874
+ const options = takeFleetOptions(args, ['--json']);
875
+ if (options.ref === null || options.extra.length > 0) {
876
+ io.stderr.write('pactive: usage: pactive fleet open --assistant <name|id> [--json]\n');
877
+ return 2;
878
+ }
879
+ const core = await loadCore();
880
+ const herdr = await loadHerdr();
881
+ const paths = core.resolvePaths(HOST_KIND, env);
882
+ const state = core.loadState(paths);
883
+ const record = core.assistants.resolve(state, options.ref);
884
+ if (!record) return unknownAssistant(core, state, options.ref, io);
885
+ if (!record.pane) {
886
+ io.stderr.write(`pactive: ${record.name} 没有绑定 pane —— 先跑 pactive fleet up --assistant ${record.name}\n`);
887
+ return 1;
888
+ }
889
+ const probe = herdr.available({ env });
890
+ if (!probe.ok) return fleetRefused(io, probe, 'open');
891
+ const focused = herdr.focus(record.pane, { env });
892
+ emit(
893
+ io,
894
+ asJson,
895
+ { ok: focused.ok, ...focused, assistantId: record.assistantId },
896
+ focused.ok
897
+ ? [`pactive: focused ${record.name} → pane ${focused.paneId ?? record.pane.paneId} (agent ${focused.agentName})`]
898
+ : [`pactive: cannot focus ${record.name} — ${focused.reason}`],
899
+ );
900
+ return focused.ok ? 0 : 1;
901
+ }
902
+
903
+ async function fleetCmd(args, env, io) {
904
+ const [sub, ...rest] = args;
905
+ if (sub === undefined || !FLEET_SUBS.includes(sub)) {
906
+ io.stderr.write(`${FLEET_USAGE}\n`);
907
+ return 2;
908
+ }
909
+ return { up: fleetUpCmd, ls: fleetLsCmd, down: fleetDownCmd, open: fleetOpenCmd }[sub](rest, env, io);
910
+ }
911
+
912
+ /**
913
+ * I79/I75 helper: give every enabled assistant a live pane before a tick delivers.
914
+ * Never fatal — herdr absent means "keep going on the outbox path", said out loud (I78).
915
+ */
916
+ async function ensureFleet(core, herdr, paths, state, env, io) {
917
+ const probe = herdr.available({ env });
918
+ if (!probe.ok) {
919
+ io.stderr.write(`pactive: --ensure-fleet 跳过 —— herdr 不可用(${probe.reason});继续按 outbox 投递\n`);
920
+ return { skipped: true, reason: probe.reason, bound: 0, reused: 0, failed: 0 };
921
+ }
922
+ const cwd = path.resolve(env.PACTIVE_FLEET_CWD ?? process.cwd());
923
+ const report = { skipped: false, reason: null, bound: 0, reused: 0, failed: 0 };
924
+ for (const target of core.assistants.list(state)) {
925
+ if (target.enabled !== true) continue;
926
+ const existing = target.pane ?? null;
927
+ if (existing && existing.paneId && herdr.paneAlive(existing.paneId, { env }).alive === true) {
928
+ report.reused += 1;
929
+ continue;
930
+ }
931
+ if (existing) {
932
+ await core.mutateState(paths, (current) => {
933
+ core.assistants.unbindPane(current, target.assistantId);
934
+ return current;
935
+ });
936
+ }
937
+ const host = target.delivery?.host ?? 'pi';
938
+ const spawned = herdr.spawn({ assistantId: target.assistantId, host, cwd }, { env });
939
+ if (!spawned.ok) {
940
+ report.failed += 1;
941
+ io.stderr.write(`pactive: --ensure-fleet 未能给 ${target.name} 建 pane — ${spawned.reason}\n`);
942
+ continue;
943
+ }
944
+ await core.mutateState(paths, (current) => {
945
+ core.assistants.bindPane(current, target.assistantId, spawned.binding);
946
+ return current;
947
+ });
948
+ report.bound += 1;
949
+ }
950
+ return report;
951
+ }
952
+
953
+ /** One drive round: due tasks + idle sweep, every attempt lands in the ledger. */
954
+ async function tickCmd(args, env, io) {
955
+ const asJson = args.includes('--json');
956
+ const wantFleet = args.includes('--ensure-fleet');
957
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json' && a !== '--ensure-fleet'), '--assistant');
958
+ const core = await loadCore();
959
+ const paths = core.resolvePaths(HOST_KIND, env);
960
+ const state = core.loadState(paths);
961
+
962
+ let assistantId = null;
963
+ if (assistantOpt.value !== null) {
964
+ const record = core.assistants.resolve(state, assistantOpt.value);
965
+ if (!record) return unknownAssistant(core, state, assistantOpt.value, io);
966
+ assistantId = record.assistantId;
967
+ }
968
+
969
+ // §2.3: with a daemon absent, `--ensure-fleet` is the opt-in "repair the panes, then deliver".
970
+ const herdr = await loadHerdr();
971
+ const fleetReport = wantFleet ? await ensureFleet(core, herdr, paths, state, env, io) : null;
972
+
973
+ // §2.2/I77: a bound pane gets the prompt through the agent surface and the *receipt* decides
974
+ // whether the ledger says `delivered`, `queued` or `unreachable`.
975
+ const probe = herdr.available({ env });
976
+ const receipts = { accepted: 0, queued: 0, unreachable: 0 };
977
+ const paneDeliver = (delivery) => {
978
+ if (!probe.ok) return { state: 'queued', reason: `herdr-unavailable:${probe.reasons[0]?.code ?? 'unknown'}` };
979
+ const receipt = herdr.deliver(
980
+ { agentName: delivery.pane.agentName, paneId: delivery.pane.paneId, text: delivery.prompt ?? IDLE_PROMPT },
981
+ { env },
982
+ );
983
+ receipts[receipt.state] = (receipts[receipt.state] ?? 0) + 1;
984
+ return receipt;
985
+ };
986
+
987
+ let report = null;
988
+ await core.mutateState(paths, (current) => {
989
+ report = core.runTick(current, Date.now(), { ledgerFile: paths.ledgerFile, assistantId, paneDeliver });
990
+ return current;
991
+ });
992
+
993
+ const blocks = report.blocks ?? [];
994
+ const queuedEvents = report.queued_events ?? [];
995
+ const unreachableEvents = report.unreachable_events ?? [];
996
+ return emit(
997
+ io,
998
+ asJson,
999
+ { ...report, receipts, fleet: fleetReport, ledgerFile: paths.ledgerFile, stateFile: paths.stateFile },
1000
+ [
1001
+ `pactive: tick — delivered=${report.delivered} queued=${report.queued} unreachable=${report.unreachable} blocked=${report.blocked} pending=${report.pending} assistants=${report.assistants}`,
1002
+ ...report.deliveries.map((d) => ` delivered ${d.assistantId}${d.paneId ? ` pane=${d.paneId}` : ''}${d.taskId ? ` ${d.taskId}` : ''}${d.prompt ? ` — ${d.prompt}` : ''}`),
1003
+ ...queuedEvents.map((q) => ` queued ${q.assistantId} pane=${q.paneId ?? '-'} reason=${q.reason}`),
1004
+ ...unreachableEvents.map((u) => ` unreachable ${u.assistantId} pane=${u.paneId ?? '-'} reason=${u.reason}(绑定已清空,可 fleet up 重开)`),
1005
+ ...blocks.map((b) => ` blocked ${b.assistantId}${b.taskId ? ` ${b.taskId}` : ''} reason=${b.reason}`),
1006
+ ],
400
1007
  );
401
1008
  }
402
1009
 
403
1010
  async function scheduleCmd(args, env, io) {
404
1011
  const asJson = args.includes('--json');
405
1012
  const confirm = args.includes('--confirm');
406
- const taken = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--when');
1013
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--assistant');
1014
+ const taken = takeOption(assistantOpt.args, '--when');
407
1015
  const prompt = taken.args.join(' ').trim();
408
1016
  if (prompt === '') {
409
- io.stderr.write('pactive: usage: pactive schedule "<prompt>" --when <when> [--confirm]\n');
1017
+ io.stderr.write('pactive: usage: pactive schedule "<prompt>" --when <when> [--assistant <name|id>] [--confirm]\n');
410
1018
  return 2;
411
1019
  }
412
1020
  const now = Date.now();
@@ -417,11 +1025,20 @@ async function scheduleCmd(args, env, io) {
417
1025
  }
418
1026
  const core = await loadCore();
419
1027
  const paths = core.resolvePaths(HOST_KIND, env);
1028
+ // An unknown assistant is refused before the write: a typo must not invent a new assistant.
1029
+ let assistantId = null;
1030
+ if (assistantOpt.value !== null) {
1031
+ const record = core.assistants.resolve(core.loadState(paths), assistantOpt.value);
1032
+ if (!record) return unknownAssistant(core, core.loadState(paths), assistantOpt.value, io);
1033
+ assistantId = record.assistantId;
1034
+ }
420
1035
  const next = await core.mutateState(paths, (state) => {
421
- core.schedule.add(state, { ...spec, prompt, content: prompt, owner: state.identity?.agentId }, now);
1036
+ // Unqualified tasks land on the `default` assistant, created on first use (never enabled).
1037
+ const targetId = assistantId ?? core.assistants.ensureDefault(state).assistantId;
1038
+ core.schedule.add(state, { ...spec, prompt, content: prompt, owner: state.identity?.agentId, assistantId: targetId }, now);
422
1039
  return state;
423
1040
  });
424
- const list = core.schedule.list(next);
1041
+ const list = core.schedule.list(next, assistantId ?? undefined);
425
1042
  const created = list[list.length - 1] ?? null;
426
1043
  return emit(
427
1044
  io,
@@ -430,6 +1047,7 @@ async function scheduleCmd(args, env, io) {
430
1047
  ok: true,
431
1048
  confirmed: confirm,
432
1049
  taskId: created?.id ?? null,
1050
+ assistantId: created?.assistantId ?? assistantId,
433
1051
  dueAt: created?.dueAt ?? null,
434
1052
  kind: spec.kind,
435
1053
  when: spec.when,
@@ -437,6 +1055,7 @@ async function scheduleCmd(args, env, io) {
437
1055
  },
438
1056
  [
439
1057
  `pactive: registered ${created?.id ?? '?'} (${spec.when} → ${new Date(created?.dueAt ?? now).toISOString()})`,
1058
+ `assistant: ${created?.assistantId ?? assistantId ?? 'default'}`,
440
1059
  `prompt: ${prompt}`,
441
1060
  ],
442
1061
  );
@@ -444,11 +1063,19 @@ async function scheduleCmd(args, env, io) {
444
1063
 
445
1064
  async function tasksCmd(args, env, io) {
446
1065
  const asJson = args.includes('--json');
1066
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json'), '--assistant');
447
1067
  const core = await loadCore();
448
1068
  const state = core.loadState(core.resolvePaths(HOST_KIND, env));
449
- const list = core.schedule.list(state);
1069
+ let assistantId;
1070
+ if (assistantOpt.value !== null) {
1071
+ const record = core.assistants.resolve(state, assistantOpt.value);
1072
+ if (!record) return unknownAssistant(core, state, assistantOpt.value, io);
1073
+ assistantId = record.assistantId;
1074
+ }
1075
+ const list = core.schedule.list(state, assistantId);
450
1076
  const rows = list.map((t) => ({
451
1077
  id: t.id,
1078
+ assistantId: t.assistantId ?? '',
452
1079
  kind: t.kind,
453
1080
  dueAt: t.dueAt,
454
1081
  due: new Date(t.dueAt).toISOString(),
@@ -459,11 +1086,11 @@ async function tasksCmd(args, env, io) {
459
1086
  return emit(
460
1087
  io,
461
1088
  asJson,
462
- { count: rows.length, tasks: rows },
1089
+ { count: rows.length, assistantId: assistantId ?? null, tasks: rows },
463
1090
  rows.length === 0
464
1091
  ? ['pactive: no scheduled tasks']
465
1092
  : rows.map(
466
- (r) => `[${r.id}] ${r.kind} ${r.due} owner=${r.owner || '-'} ${r.active ? 'active' : 'inactive'}${r.prompt ? ` — ${r.prompt}` : ''}`,
1093
+ (r) => `[${r.id}] ${r.kind} ${r.due} assistant=${r.assistantId || '-'} owner=${r.owner || '-'} ${r.active ? 'active' : 'inactive'}${r.prompt ? ` — ${r.prompt}` : ''}`,
467
1094
  ),
468
1095
  );
469
1096
  }
@@ -682,7 +1309,12 @@ export async function main(argv, env = process.env, io = { stdout: process.stdou
682
1309
  case 'schedule':
683
1310
  case 'tasks':
684
1311
  case 'cancel':
685
- return { schedule: scheduleCmd, tasks: tasksCmd, cancel: cancelCmd }[cmd](rest, env, io);
1312
+ case 'tick':
1313
+ return { schedule: scheduleCmd, tasks: tasksCmd, cancel: cancelCmd, tick: tickCmd }[cmd](rest, env, io);
1314
+ case 'assistant':
1315
+ return assistantCmd(rest, env, io);
1316
+ case 'fleet':
1317
+ return fleetCmd(rest, env, io);
686
1318
  case 'install':
687
1319
  return installCmd(rest, env, io, 'install');
688
1320
  case 'uninstall':