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/.opcteam-plugin/plugin.json +8 -4
- package/BREAKING-CHANGES.md +14 -3
- package/README.md +84 -10
- package/bin/pactive.mjs +650 -18
- package/dsh/index.js +170 -14
- package/mcp/tools/definitions.mjs +159 -13
- package/package.json +1 -1
- package/pi/extension.ts +191 -15
- package/pi/skills/pactive/SKILL.md +16 -2
- package/skills/pactive/SKILL.md +16 -2
- package/src/core/assistants.mjs +237 -0
- package/src/core/config.mjs +40 -3
- package/src/core/fleet.mjs +80 -0
- package/src/core/guard.mjs +117 -32
- package/src/core/idle.mjs +11 -8
- package/src/core/index.mjs +3 -0
- package/src/core/scheduler.mjs +10 -2
- package/src/core/state.mjs +134 -12
- package/src/core/tick.mjs +128 -0
- package/src/hosts/herdr/fleet-view.mjs +31 -0
- package/src/hosts/herdr/index.mjs +429 -0
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>
|
|
37
|
-
pactive tasks [--json]
|
|
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
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|