pactive 0.0.2-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
@@ -18,13 +18,36 @@ 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
+ 「我有几个助理?各自开了吗?」 → 列出助理、开关、下次到期
31
+
32
+ 命令(高级/运维):
25
33
  pactive <host> [args...] run a host with pactive injected (stdio inherited)
26
34
  pactive --print-plan <host> print the launch plan as JSON (no side effects)
27
35
  pactive doctor [--json] diagnose hosts, state root and permissions (read-only)
36
+ pactive config get [<key>] print current values and defaults (read-only)
37
+ pactive config set <key> <value> --confirm write one config key (human gate)
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
50
+ pactive cancel <taskId> cancel one task
28
51
  pactive install <host> register pactive with a host (idempotent)
29
52
  pactive uninstall <host> unregister pactive and remove pactive state residue
30
53
  pactive --version | -v print the pactive version
@@ -38,6 +61,7 @@ Environment:
38
61
  PACTIVE_PI_BIN override the pi executable
39
62
  PACTIVE_OPCTEAM_BIN override the opcteam executable
40
63
  PACTIVE_DSH_BIN override the dsh executable
64
+ PACTIVE_HERDR_BIN override the herdr executable(fleet 复用 herdr;无 herdr 时 fleet 明确拒绝)
41
65
  `);
42
66
  }
43
67
 
@@ -222,6 +246,882 @@ async function doctorReport(env) {
222
246
  };
223
247
  }
224
248
 
249
+ // ─────────────────── 自然语言面向的子命令(契约 §2.1 / I57–I60) ───────────────────
250
+ //
251
+ // 这里是**唯一的人工闸门**:写配置必须显式 --confirm,缺了就 exit 2 且零写盘。
252
+ // stdout 只放 --json,人类可读文本一律走 stderr(沿用 I37)。
253
+
254
+ const CONFIRM_REFUSAL = 'pactive: refusing to write without --confirm (human gate)';
255
+ const HOST_KIND = 'pi'; // the state root is host-agnostic; 'pi' keeps doctor/CLI on one spelling.
256
+ function parseDurationMs(text) {
257
+ const m = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hour|hours|d|day|days)$/i.exec(String(text ?? '').trim());
258
+ if (!m) return null;
259
+ const unit = m[2].toLowerCase();
260
+ const factor = unit.startsWith('s') ? 1000 : unit.startsWith('m') ? 60000 : unit.startsWith('h') ? 3600000 : 86400000;
261
+ return Number(m[1]) * factor;
262
+ }
263
+
264
+ /** `10m` / `18:30` / ISO 8601 / `every 1h` → a spec that `core.schedule.add` accepts directly. */
265
+ function parseWhen(when, now = Date.now()) {
266
+ const text = String(when ?? '').trim();
267
+ if (text === '') return { kind: 'in', when: '10m', delayMs: 10 * 60000 };
268
+ const every = /^every\s+(.+)$/i.exec(text);
269
+ if (every) {
270
+ const everyMs = parseDurationMs(every[1]);
271
+ if (everyMs === null || everyMs <= 0) return null;
272
+ return { kind: 'every', when: text, everyMs };
273
+ }
274
+ const delayMs = parseDurationMs(text);
275
+ if (delayMs !== null) return { kind: 'in', when: text, delayMs };
276
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(text);
277
+ if (clock) {
278
+ const at = new Date(now);
279
+ at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
280
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1);
281
+ return { kind: 'at', when: text, at: at.getTime() };
282
+ }
283
+ const iso = Date.parse(text);
284
+ if (!Number.isNaN(iso)) return { kind: 'at', when: text, at: iso };
285
+ return null;
286
+ }
287
+
288
+ /** Pull `--name <value>` / `--name=<value>` out of args. */
289
+ function takeOption(args, name) {
290
+ const rest = [];
291
+ let value = null;
292
+ for (let i = 0; i < args.length; i += 1) {
293
+ const arg = args[i];
294
+ if (arg === name) {
295
+ value = args[i + 1] ?? null;
296
+ i += 1;
297
+ continue;
298
+ }
299
+ if (arg.startsWith(`${name}=`)) {
300
+ value = arg.slice(name.length + 1);
301
+ continue;
302
+ }
303
+ rest.push(arg);
304
+ }
305
+ return { value, args: rest };
306
+ }
307
+
308
+ /** JSON on stdout, human lines on stderr — never both for the same payload. */
309
+ function emit(io, asJson, payload, humanLines) {
310
+ if (asJson) io.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
311
+ else for (const line of humanLines) io.stderr.write(`${line}\n`);
312
+ return 0;
313
+ }
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
+
325
+ function configKeys(core) {
326
+ const described = core.config.describe();
327
+ return { described, keys: Object.keys(described.defaults) };
328
+ }
329
+
330
+ function coerceValue(spec, raw) {
331
+ const text = String(raw).trim();
332
+ if (spec.type === 'boolean') {
333
+ if (text === 'true') return { ok: true, value: true };
334
+ if (text === 'false') return { ok: true, value: false };
335
+ return { ok: false, reason: 'expected true|false' };
336
+ }
337
+ if (spec.type === 'number') {
338
+ const n = Number(text);
339
+ if (text === '' || !Number.isFinite(n)) return { ok: false, reason: 'expected a number' };
340
+ if (typeof spec.min === 'number' && n < spec.min) return { ok: false, reason: `expected >= ${spec.min}` };
341
+ return { ok: true, value: n };
342
+ }
343
+ if (spec.type === 'string|null') {
344
+ if (text === '' || text.toLowerCase() === 'null' || text.toLowerCase() === 'none') return { ok: true, value: null };
345
+ return { ok: true, value: text };
346
+ }
347
+ return { ok: false, reason: `unsupported type ${spec.type}` };
348
+ }
349
+
350
+ async function configGetCmd(args, env, io) {
351
+ const asJson = args.includes('--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;
355
+ const core = await loadCore();
356
+ const { described, keys: known } = configKeys(core);
357
+ if (keys.length > 1) {
358
+ io.stderr.write('pactive: usage: pactive config get [<key>] [--assistant <name|id>] [--json]\n');
359
+ return 2;
360
+ }
361
+ const key = keys[0];
362
+ if (key !== undefined && !known.includes(key)) {
363
+ io.stderr.write(`pactive: unknown config key '${key}'\npactive: known keys: ${known.join(', ')}\n`);
364
+ return 2;
365
+ }
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
+ }
373
+ const targets = key === undefined ? known : [key];
374
+ const values = targets.map((k) => ({
375
+ key: k,
376
+ value: core.config.get(state, k, assistantId),
377
+ default: described.defaults[k],
378
+ humanGated: described.schema[k].humanGated === true,
379
+ }));
380
+ const payload = key === undefined ? { values } : values[0];
381
+ // Single key → the bare value on its own line (so `config get idle.enabled | grep -qx false` works),
382
+ // then the default; the full key list keeps one "key = value" line each.
383
+ const human = key === undefined
384
+ ? values.map(
385
+ (v) => `${v.key} = ${JSON.stringify(v.value)}${JSON.stringify(v.value) === JSON.stringify(v.default) ? ' (default)' : ''}`,
386
+ )
387
+ : [`${JSON.stringify(values[0].value)}`, `${key} (default: ${JSON.stringify(values[0].default)})`];
388
+ return emit(io, asJson, payload, human);
389
+ }
390
+
391
+ async function configSetCmd(args, env, io) {
392
+ const asJson = args.includes('--json');
393
+ const confirm = args.includes('--confirm');
394
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--assistant');
395
+ const rest = assistantOpt.args;
396
+ const [key, raw] = rest;
397
+ const core = await loadCore();
398
+ const { described, keys: known } = configKeys(core);
399
+
400
+ // Validity first (a usage error must not read as "gate refused"), then the gate.
401
+ if (key === undefined || raw === undefined || rest.length > 2) {
402
+ io.stderr.write('pactive: usage: pactive config set <key> <value> [--assistant <name|id>] --confirm\n');
403
+ return 2;
404
+ }
405
+ if (!known.includes(key)) {
406
+ io.stderr.write(`pactive: unknown config key '${key}'\npactive: known keys: ${known.join(', ')}\n`);
407
+ return 2;
408
+ }
409
+ if (!confirm) {
410
+ io.stderr.write(`${CONFIRM_REFUSAL}\n`);
411
+ io.stderr.write(`pactive: re-run with --confirm: pactive config set ${key} ${raw} --confirm\n`);
412
+ return 2;
413
+ }
414
+ const coerced = coerceValue(described.schema[key], raw);
415
+ if (!coerced.ok) {
416
+ io.stderr.write(`pactive: invalid value for ${key}: ${coerced.reason}\n`);
417
+ return 2;
418
+ }
419
+
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
+
434
+ let applied = null;
435
+ await core.mutateState(paths, (state) => {
436
+ applied = core.config.setHumanGated(state, key, coerced.value, { confirm: true, assistantId });
437
+ if (!applied.ok) throw new PactiveError(`config refused: ${applied.reason}`, { code: 'CONFIG_REFUSED', exitCode: 1 });
438
+ return state;
439
+ });
440
+ return emit(
441
+ io,
442
+ asJson,
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
+ ],
1007
+ );
1008
+ }
1009
+
1010
+ async function scheduleCmd(args, env, io) {
1011
+ const asJson = args.includes('--json');
1012
+ const confirm = args.includes('--confirm');
1013
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--assistant');
1014
+ const taken = takeOption(assistantOpt.args, '--when');
1015
+ const prompt = taken.args.join(' ').trim();
1016
+ if (prompt === '') {
1017
+ io.stderr.write('pactive: usage: pactive schedule "<prompt>" --when <when> [--assistant <name|id>] [--confirm]\n');
1018
+ return 2;
1019
+ }
1020
+ const now = Date.now();
1021
+ const spec = parseWhen(taken.value ?? '10m', now);
1022
+ if (!spec) {
1023
+ io.stderr.write(`pactive: cannot parse --when '${taken.value}' (try 10m / 18:30 / ISO 8601 / every 1h)\n`);
1024
+ return 2;
1025
+ }
1026
+ const core = await loadCore();
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
+ }
1035
+ const next = await core.mutateState(paths, (state) => {
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);
1039
+ return state;
1040
+ });
1041
+ const list = core.schedule.list(next, assistantId ?? undefined);
1042
+ const created = list[list.length - 1] ?? null;
1043
+ return emit(
1044
+ io,
1045
+ asJson,
1046
+ {
1047
+ ok: true,
1048
+ confirmed: confirm,
1049
+ taskId: created?.id ?? null,
1050
+ assistantId: created?.assistantId ?? assistantId,
1051
+ dueAt: created?.dueAt ?? null,
1052
+ kind: spec.kind,
1053
+ when: spec.when,
1054
+ tasks: list.length,
1055
+ },
1056
+ [
1057
+ `pactive: registered ${created?.id ?? '?'} (${spec.when} → ${new Date(created?.dueAt ?? now).toISOString()})`,
1058
+ `assistant: ${created?.assistantId ?? assistantId ?? 'default'}`,
1059
+ `prompt: ${prompt}`,
1060
+ ],
1061
+ );
1062
+ }
1063
+
1064
+ async function tasksCmd(args, env, io) {
1065
+ const asJson = args.includes('--json');
1066
+ const assistantOpt = takeOption(args.filter((a) => a !== '--json'), '--assistant');
1067
+ const core = await loadCore();
1068
+ const state = core.loadState(core.resolvePaths(HOST_KIND, env));
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);
1076
+ const rows = list.map((t) => ({
1077
+ id: t.id,
1078
+ assistantId: t.assistantId ?? '',
1079
+ kind: t.kind,
1080
+ dueAt: t.dueAt,
1081
+ due: new Date(t.dueAt).toISOString(),
1082
+ owner: t.owner ?? '',
1083
+ active: t.active !== false,
1084
+ prompt: t.prompt ?? '',
1085
+ }));
1086
+ return emit(
1087
+ io,
1088
+ asJson,
1089
+ { count: rows.length, assistantId: assistantId ?? null, tasks: rows },
1090
+ rows.length === 0
1091
+ ? ['pactive: no scheduled tasks']
1092
+ : rows.map(
1093
+ (r) => `[${r.id}] ${r.kind} ${r.due} assistant=${r.assistantId || '-'} owner=${r.owner || '-'} ${r.active ? 'active' : 'inactive'}${r.prompt ? ` — ${r.prompt}` : ''}`,
1094
+ ),
1095
+ );
1096
+ }
1097
+
1098
+ async function cancelCmd(args, env, io) {
1099
+ const asJson = args.includes('--json');
1100
+ const confirm = args.includes('--confirm');
1101
+ const id = args.filter((a) => a !== '--json' && a !== '--confirm')[0];
1102
+ if (id === undefined) {
1103
+ io.stderr.write('pactive: usage: pactive cancel <taskId> [--confirm]\n');
1104
+ return 2;
1105
+ }
1106
+ const core = await loadCore();
1107
+ const paths = core.resolvePaths(HOST_KIND, env);
1108
+ const before = core.schedule.list(core.loadState(paths));
1109
+ if (!before.some((t) => t.id === id)) {
1110
+ io.stderr.write(`pactive: unknown task id '${id}'\n`);
1111
+ return 1;
1112
+ }
1113
+ const next = await core.mutateState(paths, (state) => {
1114
+ core.schedule.cancel(state, id);
1115
+ return state;
1116
+ });
1117
+ return emit(
1118
+ io,
1119
+ asJson,
1120
+ { ok: true, confirmed: confirm, cancelled: id, tasks: core.schedule.list(next).length },
1121
+ [`pactive: cancelled ${id} (${core.schedule.list(next).length} left)`],
1122
+ );
1123
+ }
1124
+
225
1125
  function printPlan(args, env, io) {
226
1126
  const host = args[0];
227
1127
  if (!host) {
@@ -396,12 +1296,25 @@ export async function main(argv, env = process.env, io = { stdout: process.stdou
396
1296
  return 0;
397
1297
  case '--help':
398
1298
  case '-h':
399
- usage(io.stderr);
1299
+ // Explicit help goes to stdout: it is the requested output, not host noise
1300
+ // (I37 protects the stdout of a *spawned host*; `--help | head -8` must be greppable).
1301
+ usage(io.stdout);
400
1302
  return 0;
401
1303
  case '--print-plan':
402
1304
  return printPlan(rest, env, io);
403
1305
  case 'doctor':
404
1306
  return doctorCmd(rest, env, io);
1307
+ case 'config':
1308
+ return rest[0] === 'set' ? configSetCmd(rest.slice(1), env, io) : configGetCmd(rest[0] === 'get' ? rest.slice(1) : rest, env, io);
1309
+ case 'schedule':
1310
+ case 'tasks':
1311
+ case 'cancel':
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);
405
1318
  case 'install':
406
1319
  return installCmd(rest, env, io, 'install');
407
1320
  case 'uninstall':