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/.opcteam-plugin/plugin.json +8 -4
- package/BREAKING-CHANGES.md +14 -3
- package/README.md +86 -6
- package/bin/pactive.mjs +917 -4
- package/commands/pactive.md +4 -1
- package/dsh/index.js +214 -6
- package/mcp/tools/definitions.mjs +205 -8
- package/package.json +1 -1
- package/pi/extension.ts +246 -8
- package/pi/skills/pactive/SKILL.md +57 -20
- package/skills/pactive/SKILL.md +57 -20
- 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 +16 -4
- 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
|
@@ -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(
|
|
22
|
-
|
|
21
|
+
function usage(out) {
|
|
22
|
+
out.write(`pactive — 主动助手:默认全部用自然语言操作,命令只是"可复制的一行"
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
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':
|