pactive 0.0.1-alpha.1 → 0.0.3-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.
@@ -0,0 +1,720 @@
1
+ #!/usr/bin/env node
2
+ // Pactive CLI — contract §3 / design §6.
3
+ //
4
+ // Output discipline (design §6.3): machine-readable results (--print-plan, doctor --json,
5
+ // --version) go to stdout; every human/diagnostic Pactive text goes to stderr, so the stdout of a
6
+ // spawned host is never polluted.
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+
13
+ import { PactiveError } from '../src/hosts/errors.mjs';
14
+
15
+ const HOSTS = ['pi', 'opcteam', 'dsh'];
16
+ const CLI_DIR = path.dirname(fileURLToPath(import.meta.url));
17
+ const PKG_ROOT = path.resolve(CLI_DIR, '..');
18
+ const FALLBACK_VERSION = '0.0.2-alpha.1';
19
+ const STALE_LOCK_MS = 30 * 60 * 1000;
20
+
21
+ function usage(out) {
22
+ out.write(`pactive — 主动助手:默认全部用自然语言操作,命令只是"可复制的一行"
23
+
24
+ 你可以直接对助手说:
25
+ 「我现在闲不闲?」 → 看空闲判定与 reason
26
+ 「10 分钟后提醒我喝水」 → 登记定时任务
27
+ 「主动提醒开启了吗?」 → 看开关状态
28
+ 「帮我开启主动提醒」 → 给出可复制命令(人工确认后才生效)
29
+
30
+ 命令(高级/运维):
31
+ pactive <host> [args...] run a host with pactive injected (stdio inherited)
32
+ pactive --print-plan <host> print the launch plan as JSON (no side effects)
33
+ pactive doctor [--json] diagnose hosts, state root and permissions (read-only)
34
+ pactive config get [<key>] print current values and defaults (read-only)
35
+ pactive config set <key> <value> --confirm write one config key (human gate)
36
+ pactive schedule "<prompt>" --when <when> register a timed task (10m / 18:30 / ISO / every 1h)
37
+ pactive tasks [--json] list scheduled tasks (id / kind / due / owner)
38
+ pactive cancel <taskId> cancel one task
39
+ pactive install <host> register pactive with a host (idempotent)
40
+ pactive uninstall <host> unregister pactive and remove pactive state residue
41
+ pactive --version | -v print the pactive version
42
+ pactive --help | -h show this help
43
+
44
+ Hosts: ${HOSTS.join(', ')}
45
+
46
+ Environment:
47
+ PACTIVE_HOME package root override (default: this package)
48
+ PACTIVE_STATE_DIR pactive state root override
49
+ PACTIVE_PI_BIN override the pi executable
50
+ PACTIVE_OPCTEAM_BIN override the opcteam executable
51
+ PACTIVE_DSH_BIN override the dsh executable
52
+ `);
53
+ }
54
+
55
+ function unknownHost(host, io) {
56
+ io.write(`pactive: unknown host '${host}'\n`);
57
+ io.write(`pactive: known hosts: ${HOSTS.join(', ')}\n`);
58
+ }
59
+
60
+ function resolvePkgRoot(env) {
61
+ return env.PACTIVE_HOME ? path.resolve(env.PACTIVE_HOME) : PKG_ROOT;
62
+ }
63
+
64
+ function pactiveVersion() {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
67
+ if (typeof pkg.version === 'string') return pkg.version;
68
+ } catch {
69
+ // package.json not built yet — fall back to the contract version
70
+ }
71
+ return FALLBACK_VERSION;
72
+ }
73
+
74
+ async function loadAdapter(host) {
75
+ return import(new URL(`../src/hosts/${host}/index.mjs`, import.meta.url));
76
+ }
77
+
78
+ /** src/core is built by the pactive-core worker; until then the CLI degrades instead of faking it. */
79
+ async function loadCore() {
80
+ try {
81
+ return await import(new URL('../src/core/index.mjs', import.meta.url));
82
+ } catch (err) {
83
+ throw new PactiveError('PACTIVE core not built yet', { code: 'CORE_MISSING', exitCode: 1, cause: err });
84
+ }
85
+ }
86
+
87
+ async function resolveStateRoot(env, warnings = []) {
88
+ if (env.PACTIVE_STATE_DIR) return path.resolve(env.PACTIVE_STATE_DIR);
89
+ try {
90
+ const core = await loadCore();
91
+ const paths = core.resolvePaths('pi', env);
92
+ if (paths?.root) return paths.root;
93
+ warnings.push('PACTIVE core returned no state root — using the default location');
94
+ } catch (err) {
95
+ warnings.push(`${err.message} — using the default state root`);
96
+ }
97
+ return path.join(os.homedir(), '.pactive');
98
+ }
99
+
100
+ function modeOf(p) {
101
+ return (statSync(p).mode & 0o777).toString(8).padStart(4, '0');
102
+ }
103
+
104
+ function probeVersion(exec) {
105
+ const res = spawnSync(exec, ['--version'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
106
+ const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.trim();
107
+ if (!out) return null;
108
+ return out.split(/\s+/)[0];
109
+ }
110
+
111
+ function majorOf(version) {
112
+ const major = Number.parseInt(String(version).split('.')[0], 10);
113
+ return Number.isNaN(major) ? null : major;
114
+ }
115
+
116
+ function isCompatible(found, supported) {
117
+ if (!found || !supported) return false;
118
+ const a = majorOf(found);
119
+ const b = majorOf(supported);
120
+ return a !== null && b !== null && a === b;
121
+ }
122
+
123
+ function countFiles(dir) {
124
+ if (!existsSync(dir)) return 0;
125
+ let n = 0;
126
+ for (const name of readdirSync(dir)) {
127
+ const abs = path.join(dir, name);
128
+ if (statSync(abs).isDirectory()) n += countFiles(abs);
129
+ else n += 1;
130
+ }
131
+ return n;
132
+ }
133
+
134
+ function stateStats(stateRoot) {
135
+ const stats = {
136
+ exists: existsSync(stateRoot),
137
+ perm: { dir: null, file: null },
138
+ idle: { enabled: false },
139
+ sessions: 0,
140
+ tasks: 0,
141
+ outbox: 0,
142
+ locksStale: 0,
143
+ fails: [],
144
+ };
145
+ if (!stats.exists) return stats;
146
+
147
+ stats.perm.dir = modeOf(stateRoot);
148
+ if (stats.perm.dir !== '0700') {
149
+ stats.fails.push(`state root ${stateRoot} has mode ${stats.perm.dir}, expected 0700`);
150
+ }
151
+
152
+ const stateFile = path.join(stateRoot, 'state.json');
153
+ if (existsSync(stateFile)) {
154
+ stats.perm.file = modeOf(stateFile);
155
+ if (stats.perm.file !== '0600') {
156
+ stats.fails.push(`state file ${stateFile} has mode ${stats.perm.file}, expected 0600`);
157
+ }
158
+ try {
159
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'));
160
+ stats.idle.enabled = Boolean(state?.idle?.enabled);
161
+ if (Array.isArray(state?.tasks)) stats.tasks = state.tasks.length;
162
+ } catch {
163
+ stats.fails.push(`state file ${stateFile} is not valid JSON`);
164
+ }
165
+ }
166
+
167
+ const tasksFile = path.join(stateRoot, 'tasks.json');
168
+ if (existsSync(tasksFile)) {
169
+ try {
170
+ const tasks = JSON.parse(readFileSync(tasksFile, 'utf8'));
171
+ if (Array.isArray(tasks)) stats.tasks = tasks.length;
172
+ } catch {
173
+ stats.fails.push(`tasks file ${tasksFile} is not valid JSON`);
174
+ }
175
+ }
176
+
177
+ stats.sessions = countFiles(path.join(stateRoot, 'peers'));
178
+ stats.outbox = countFiles(path.join(stateRoot, 'outbox'));
179
+
180
+ const locksDir = path.join(stateRoot, 'locks');
181
+ if (existsSync(locksDir)) {
182
+ const now = Date.now();
183
+ for (const name of readdirSync(locksDir)) {
184
+ const abs = path.join(locksDir, name);
185
+ if (statSync(abs).isFile() && now - statSync(abs).mtimeMs > STALE_LOCK_MS) stats.locksStale += 1;
186
+ }
187
+ }
188
+ return stats;
189
+ }
190
+
191
+ async function doctorReport(env) {
192
+ const warnings = [];
193
+ const stateRoot = await resolveStateRoot(env, warnings);
194
+ const hosts = [];
195
+ for (const host of HOSTS) {
196
+ const mod = await loadAdapter(host);
197
+ let present = false;
198
+ let version = null;
199
+ let compatible = false;
200
+ try {
201
+ const exec = mod.resolveExec({ env });
202
+ present = true;
203
+ version = probeVersion(exec);
204
+ compatible = isCompatible(version, mod.SUPPORTED_VERSION);
205
+ if (!version) {
206
+ warnings.push(`host ${host}: installed but its version could not be read`);
207
+ } else if (!compatible) {
208
+ warnings.push(`host ${host}: version ${version} differs from the supported ${mod.SUPPORTED_VERSION}`);
209
+ }
210
+ } catch (err) {
211
+ if (err.code !== 'HOST_MISSING') throw err;
212
+ warnings.push(`host ${host}: not installed (set PACTIVE_${host.toUpperCase()}_BIN to override)`);
213
+ }
214
+ hosts.push({ host, present, version, mode: mod.MODE, compatible });
215
+ }
216
+
217
+ const stats = stateStats(stateRoot);
218
+ if (!stats.exists) warnings.push(`state root ${stateRoot} does not exist yet`);
219
+ for (const fail of stats.fails) warnings.push(`FAIL: ${fail}`);
220
+
221
+ return {
222
+ ok: stats.fails.length === 0,
223
+ hosts,
224
+ stateRoot,
225
+ perm: stats.perm,
226
+ idle: { enabled: stats.idle.enabled, source: 'core-timeline' },
227
+ sessions: stats.sessions,
228
+ tasks: stats.tasks,
229
+ outbox: stats.outbox,
230
+ locksStale: stats.locksStale,
231
+ secrets: { credentialsRead: false },
232
+ warnings,
233
+ };
234
+ }
235
+
236
+ // ─────────────────── 自然语言面向的子命令(契约 §2.1 / I57–I60) ───────────────────
237
+ //
238
+ // 这里是**唯一的人工闸门**:写配置必须显式 --confirm,缺了就 exit 2 且零写盘。
239
+ // stdout 只放 --json,人类可读文本一律走 stderr(沿用 I37)。
240
+
241
+ const CONFIRM_REFUSAL = 'pactive: refusing to write without --confirm (human gate)';
242
+ const HOST_KIND = 'pi'; // the state root is host-agnostic; 'pi' keeps doctor/CLI on one spelling.
243
+ function parseDurationMs(text) {
244
+ const m = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hour|hours|d|day|days)$/i.exec(String(text ?? '').trim());
245
+ if (!m) return null;
246
+ const unit = m[2].toLowerCase();
247
+ const factor = unit.startsWith('s') ? 1000 : unit.startsWith('m') ? 60000 : unit.startsWith('h') ? 3600000 : 86400000;
248
+ return Number(m[1]) * factor;
249
+ }
250
+
251
+ /** `10m` / `18:30` / ISO 8601 / `every 1h` → a spec that `core.schedule.add` accepts directly. */
252
+ function parseWhen(when, now = Date.now()) {
253
+ const text = String(when ?? '').trim();
254
+ if (text === '') return { kind: 'in', when: '10m', delayMs: 10 * 60000 };
255
+ const every = /^every\s+(.+)$/i.exec(text);
256
+ if (every) {
257
+ const everyMs = parseDurationMs(every[1]);
258
+ if (everyMs === null || everyMs <= 0) return null;
259
+ return { kind: 'every', when: text, everyMs };
260
+ }
261
+ const delayMs = parseDurationMs(text);
262
+ if (delayMs !== null) return { kind: 'in', when: text, delayMs };
263
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(text);
264
+ if (clock) {
265
+ const at = new Date(now);
266
+ at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
267
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1);
268
+ return { kind: 'at', when: text, at: at.getTime() };
269
+ }
270
+ const iso = Date.parse(text);
271
+ if (!Number.isNaN(iso)) return { kind: 'at', when: text, at: iso };
272
+ return null;
273
+ }
274
+
275
+ /** Pull `--name <value>` / `--name=<value>` out of args. */
276
+ function takeOption(args, name) {
277
+ const rest = [];
278
+ let value = null;
279
+ for (let i = 0; i < args.length; i += 1) {
280
+ const arg = args[i];
281
+ if (arg === name) {
282
+ value = args[i + 1] ?? null;
283
+ i += 1;
284
+ continue;
285
+ }
286
+ if (arg.startsWith(`${name}=`)) {
287
+ value = arg.slice(name.length + 1);
288
+ continue;
289
+ }
290
+ rest.push(arg);
291
+ }
292
+ return { value, args: rest };
293
+ }
294
+
295
+ /** JSON on stdout, human lines on stderr — never both for the same payload. */
296
+ function emit(io, asJson, payload, humanLines) {
297
+ if (asJson) io.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
298
+ else for (const line of humanLines) io.stderr.write(`${line}\n`);
299
+ return 0;
300
+ }
301
+
302
+ function configKeys(core) {
303
+ const described = core.config.describe();
304
+ return { described, keys: Object.keys(described.defaults) };
305
+ }
306
+
307
+ function coerceValue(spec, raw) {
308
+ const text = String(raw).trim();
309
+ if (spec.type === 'boolean') {
310
+ if (text === 'true') return { ok: true, value: true };
311
+ if (text === 'false') return { ok: true, value: false };
312
+ return { ok: false, reason: 'expected true|false' };
313
+ }
314
+ if (spec.type === 'number') {
315
+ const n = Number(text);
316
+ if (text === '' || !Number.isFinite(n)) return { ok: false, reason: 'expected a number' };
317
+ if (typeof spec.min === 'number' && n < spec.min) return { ok: false, reason: `expected >= ${spec.min}` };
318
+ return { ok: true, value: n };
319
+ }
320
+ if (spec.type === 'string|null') {
321
+ if (text === '' || text.toLowerCase() === 'null' || text.toLowerCase() === 'none') return { ok: true, value: null };
322
+ return { ok: true, value: text };
323
+ }
324
+ return { ok: false, reason: `unsupported type ${spec.type}` };
325
+ }
326
+
327
+ async function configGetCmd(args, env, io) {
328
+ const asJson = args.includes('--json');
329
+ const keys = args.filter((a) => a !== '--json');
330
+ const core = await loadCore();
331
+ const { described, keys: known } = configKeys(core);
332
+ if (keys.length > 1) {
333
+ io.stderr.write('pactive: usage: pactive config get [<key>] [--json]\n');
334
+ return 2;
335
+ }
336
+ const key = keys[0];
337
+ if (key !== undefined && !known.includes(key)) {
338
+ io.stderr.write(`pactive: unknown config key '${key}'\npactive: known keys: ${known.join(', ')}\n`);
339
+ return 2;
340
+ }
341
+ const state = core.loadState(core.resolvePaths(HOST_KIND, env));
342
+ const targets = key === undefined ? known : [key];
343
+ const values = targets.map((k) => ({
344
+ key: k,
345
+ value: core.config.get(state, k),
346
+ default: described.defaults[k],
347
+ humanGated: described.schema[k].humanGated === true,
348
+ }));
349
+ const payload = key === undefined ? { values } : values[0];
350
+ // Single key → the bare value on its own line (so `config get idle.enabled | grep -qx false` works),
351
+ // then the default; the full key list keeps one "key = value" line each.
352
+ const human = key === undefined
353
+ ? values.map(
354
+ (v) => `${v.key} = ${JSON.stringify(v.value)}${JSON.stringify(v.value) === JSON.stringify(v.default) ? ' (default)' : ''}`,
355
+ )
356
+ : [`${JSON.stringify(values[0].value)}`, `${key} (default: ${JSON.stringify(values[0].default)})`];
357
+ return emit(io, asJson, payload, human);
358
+ }
359
+
360
+ async function configSetCmd(args, env, io) {
361
+ const asJson = args.includes('--json');
362
+ const confirm = args.includes('--confirm');
363
+ const rest = args.filter((a) => a !== '--json' && a !== '--confirm');
364
+ const [key, raw] = rest;
365
+ const core = await loadCore();
366
+ const { described, keys: known } = configKeys(core);
367
+
368
+ // Validity first (a usage error must not read as "gate refused"), then the gate.
369
+ if (key === undefined || raw === undefined || rest.length > 2) {
370
+ io.stderr.write('pactive: usage: pactive config set <key> <value> --confirm\n');
371
+ return 2;
372
+ }
373
+ if (!known.includes(key)) {
374
+ io.stderr.write(`pactive: unknown config key '${key}'\npactive: known keys: ${known.join(', ')}\n`);
375
+ return 2;
376
+ }
377
+ if (!confirm) {
378
+ io.stderr.write(`${CONFIRM_REFUSAL}\n`);
379
+ io.stderr.write(`pactive: re-run with --confirm: pactive config set ${key} ${raw} --confirm\n`);
380
+ return 2;
381
+ }
382
+ const coerced = coerceValue(described.schema[key], raw);
383
+ if (!coerced.ok) {
384
+ io.stderr.write(`pactive: invalid value for ${key}: ${coerced.reason}\n`);
385
+ return 2;
386
+ }
387
+
388
+ const paths = core.resolvePaths(HOST_KIND, env);
389
+ let applied = null;
390
+ await core.mutateState(paths, (state) => {
391
+ applied = core.config.setHumanGated(state, key, coerced.value, { confirm: true });
392
+ if (!applied.ok) throw new PactiveError(`config refused: ${applied.reason}`, { code: 'CONFIG_REFUSED', exitCode: 1 });
393
+ return state;
394
+ });
395
+ return emit(
396
+ io,
397
+ asJson,
398
+ { ok: true, key, value: coerced.value, stateFile: paths.stateFile },
399
+ [`${key} = ${JSON.stringify(coerced.value)}`, `pactive: wrote ${paths.stateFile}`],
400
+ );
401
+ }
402
+
403
+ async function scheduleCmd(args, env, io) {
404
+ const asJson = args.includes('--json');
405
+ const confirm = args.includes('--confirm');
406
+ const taken = takeOption(args.filter((a) => a !== '--json' && a !== '--confirm'), '--when');
407
+ const prompt = taken.args.join(' ').trim();
408
+ if (prompt === '') {
409
+ io.stderr.write('pactive: usage: pactive schedule "<prompt>" --when <when> [--confirm]\n');
410
+ return 2;
411
+ }
412
+ const now = Date.now();
413
+ const spec = parseWhen(taken.value ?? '10m', now);
414
+ if (!spec) {
415
+ io.stderr.write(`pactive: cannot parse --when '${taken.value}' (try 10m / 18:30 / ISO 8601 / every 1h)\n`);
416
+ return 2;
417
+ }
418
+ const core = await loadCore();
419
+ const paths = core.resolvePaths(HOST_KIND, env);
420
+ const next = await core.mutateState(paths, (state) => {
421
+ core.schedule.add(state, { ...spec, prompt, content: prompt, owner: state.identity?.agentId }, now);
422
+ return state;
423
+ });
424
+ const list = core.schedule.list(next);
425
+ const created = list[list.length - 1] ?? null;
426
+ return emit(
427
+ io,
428
+ asJson,
429
+ {
430
+ ok: true,
431
+ confirmed: confirm,
432
+ taskId: created?.id ?? null,
433
+ dueAt: created?.dueAt ?? null,
434
+ kind: spec.kind,
435
+ when: spec.when,
436
+ tasks: list.length,
437
+ },
438
+ [
439
+ `pactive: registered ${created?.id ?? '?'} (${spec.when} → ${new Date(created?.dueAt ?? now).toISOString()})`,
440
+ `prompt: ${prompt}`,
441
+ ],
442
+ );
443
+ }
444
+
445
+ async function tasksCmd(args, env, io) {
446
+ const asJson = args.includes('--json');
447
+ const core = await loadCore();
448
+ const state = core.loadState(core.resolvePaths(HOST_KIND, env));
449
+ const list = core.schedule.list(state);
450
+ const rows = list.map((t) => ({
451
+ id: t.id,
452
+ kind: t.kind,
453
+ dueAt: t.dueAt,
454
+ due: new Date(t.dueAt).toISOString(),
455
+ owner: t.owner ?? '',
456
+ active: t.active !== false,
457
+ prompt: t.prompt ?? '',
458
+ }));
459
+ return emit(
460
+ io,
461
+ asJson,
462
+ { count: rows.length, tasks: rows },
463
+ rows.length === 0
464
+ ? ['pactive: no scheduled tasks']
465
+ : rows.map(
466
+ (r) => `[${r.id}] ${r.kind} ${r.due} owner=${r.owner || '-'} ${r.active ? 'active' : 'inactive'}${r.prompt ? ` — ${r.prompt}` : ''}`,
467
+ ),
468
+ );
469
+ }
470
+
471
+ async function cancelCmd(args, env, io) {
472
+ const asJson = args.includes('--json');
473
+ const confirm = args.includes('--confirm');
474
+ const id = args.filter((a) => a !== '--json' && a !== '--confirm')[0];
475
+ if (id === undefined) {
476
+ io.stderr.write('pactive: usage: pactive cancel <taskId> [--confirm]\n');
477
+ return 2;
478
+ }
479
+ const core = await loadCore();
480
+ const paths = core.resolvePaths(HOST_KIND, env);
481
+ const before = core.schedule.list(core.loadState(paths));
482
+ if (!before.some((t) => t.id === id)) {
483
+ io.stderr.write(`pactive: unknown task id '${id}'\n`);
484
+ return 1;
485
+ }
486
+ const next = await core.mutateState(paths, (state) => {
487
+ core.schedule.cancel(state, id);
488
+ return state;
489
+ });
490
+ return emit(
491
+ io,
492
+ asJson,
493
+ { ok: true, confirmed: confirm, cancelled: id, tasks: core.schedule.list(next).length },
494
+ [`pactive: cancelled ${id} (${core.schedule.list(next).length} left)`],
495
+ );
496
+ }
497
+
498
+ function printPlan(args, env, io) {
499
+ const host = args[0];
500
+ if (!host) {
501
+ unknownHost('', io.stderr);
502
+ return Promise.resolve(2);
503
+ }
504
+ if (!HOSTS.includes(host)) {
505
+ unknownHost(host, io.stderr);
506
+ return Promise.resolve(2);
507
+ }
508
+ return loadAdapter(host).then((mod) => {
509
+ const plan = mod.plan({ pkgRoot: resolvePkgRoot(env), env, userArgs: [] });
510
+ io.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
511
+ return 0;
512
+ });
513
+ }
514
+
515
+ function doctorCmd(args, env, io) {
516
+ const asJson = args.includes('--json');
517
+ return doctorReport(env).then((report) => {
518
+ if (asJson) {
519
+ io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
520
+ } else {
521
+ io.stderr.write(`pactive doctor: ${report.ok ? 'OK' : 'FAIL'}\n`);
522
+ io.stderr.write(`state root: ${report.stateRoot} (dir ${report.perm.dir}, file ${report.perm.file})\n`);
523
+ for (const h of report.hosts) {
524
+ io.stderr.write(
525
+ `host ${h.host}: present=${h.present} version=${h.version ?? 'unknown'} mode=${h.mode} compatible=${h.compatible}\n`,
526
+ );
527
+ }
528
+ io.stderr.write(
529
+ `sessions=${report.sessions} tasks=${report.tasks} outbox=${report.outbox} locksStale=${report.locksStale} idle.enabled=${report.idle.enabled}\n`,
530
+ );
531
+ for (const w of report.warnings) io.stderr.write(`- ${w}\n`);
532
+ }
533
+ return report.ok ? 0 : 1;
534
+ });
535
+ }
536
+
537
+ function runHostCommand(exec, args) {
538
+ const res = spawnSync(exec, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
539
+ return { status: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
540
+ }
541
+
542
+ function verifyRegistered(mod, exec, pkgRoot) {
543
+ const res = runHostCommand(exec, mod.verifyArgs({ pkgRoot }));
544
+ return mod.verifyMatch({ pkgRoot }, `${res.stdout}\n${res.stderr}`);
545
+ }
546
+
547
+ const OWNED_STATE_ENTRIES = ['state.json', 'tasks.json', 'ledger.jsonl', 'peers', 'inbox', 'outbox', 'locks'];
548
+
549
+ function removeStateResidue(stateRoot) {
550
+ if (!existsSync(stateRoot)) return [];
551
+ for (const name of OWNED_STATE_ENTRIES) {
552
+ rmSync(path.join(stateRoot, name), { recursive: true, force: true });
553
+ }
554
+ for (const name of readdirSync(stateRoot)) {
555
+ if (/^(state|tasks)\.json\.corrupt-/.test(name)) {
556
+ rmSync(path.join(stateRoot, name), { recursive: true, force: true });
557
+ }
558
+ }
559
+ const left = [];
560
+ const walk = (dir, rel) => {
561
+ for (const name of readdirSync(dir)) {
562
+ const abs = path.join(dir, name);
563
+ const relPath = rel ? path.join(rel, name) : name;
564
+ if (statSync(abs).isDirectory()) walk(abs, relPath);
565
+ else left.push(relPath);
566
+ }
567
+ };
568
+ walk(stateRoot, '');
569
+ return left;
570
+ }
571
+
572
+ function installCmd(args, env, io, action) {
573
+ const host = args[0];
574
+ if (!host) {
575
+ unknownHost('', io.stderr);
576
+ return Promise.resolve(2);
577
+ }
578
+ if (!HOSTS.includes(host)) {
579
+ unknownHost(host, io.stderr);
580
+ return Promise.resolve(2);
581
+ }
582
+ return loadAdapter(host).then(async (mod) => {
583
+ const exec = mod.resolveExec({ env });
584
+ const pkgRoot = resolvePkgRoot(env);
585
+ if (action === 'uninstall' && typeof mod.isInstalled === 'function') {
586
+ let installed = true;
587
+ try { installed = mod.isInstalled({ pkgRoot, env }); } catch { installed = true; }
588
+ if (!installed) {
589
+ // 幂等:没装就什么都不跑(跑宿主 remove 会让 DSH 重建 profile,制造"删了又出现"的怪象)
590
+ if (typeof mod.cleanup === 'function') {
591
+ try { mod.cleanup({ pkgRoot, env }); } catch { /* residue 清理失败由下方 residue 检查兜底 */ }
592
+ }
593
+ io.stderr.write(`pactive: ${host} not installed — nothing to uninstall\n`);
594
+ return 0;
595
+ }
596
+ }
597
+ const argsFor = action === 'install' ? mod.installArgs({ pkgRoot }) : mod.uninstallArgs({ pkgRoot });
598
+ const res = runHostCommand(exec, argsFor);
599
+
600
+ if (res.status !== 0) {
601
+ const stillRegistered = verifyRegistered(mod, exec, pkgRoot);
602
+ if (action === 'install' ? !stillRegistered : stillRegistered) {
603
+ io.stderr.write(`pactive: ${action} ${host} failed (exit ${res.status})\n`);
604
+ if (res.stderr.trim()) io.stderr.write(`${res.stderr.trim()}\n`);
605
+ return 1;
606
+ }
607
+ }
608
+
609
+ if (action === 'uninstall') {
610
+ // Host-level residue: adapters may own resources Pactive created (e.g. the DSH profile dir).
611
+ if (typeof mod.cleanup === 'function') {
612
+ try {
613
+ const cleaned = mod.cleanup({ pkgRoot, env });
614
+ if (cleaned && cleaned.removed) io.stderr.write(`pactive: removed ${cleaned.path}\n`);
615
+ } catch (err) {
616
+ io.stderr.write(`pactive: cleanup warning: ${err?.message ?? err}\n`);
617
+ }
618
+ }
619
+ const stateRoot = await resolveStateRoot(env, []);
620
+ const residue = removeStateResidue(stateRoot);
621
+ if (residue.length > 0) {
622
+ io.stderr.write(`pactive: residue left in ${stateRoot}: ${residue.join(', ')}\n`);
623
+ return 1;
624
+ }
625
+ }
626
+
627
+ io.stderr.write(`pactive: ${action} ${host} ok (${pkgRoot})\n`);
628
+ return 0;
629
+ });
630
+ }
631
+
632
+ function runHost(host, userArgs, env, io) {
633
+ return loadAdapter(host).then((mod) => {
634
+ const exec = mod.resolveExec({ env });
635
+ const pkgRoot = resolvePkgRoot(env);
636
+ const argv = mod.buildArgv({ pkgRoot, userArgs });
637
+ io.stderr.write(`pactive: launching ${host} (${exec})\n`);
638
+ return new Promise((resolve) => {
639
+ const child = spawn(exec, argv, { stdio: 'inherit', env });
640
+ const forward = (sig) => {
641
+ if (!child.killed) child.kill(sig);
642
+ };
643
+ process.on('SIGINT', forward);
644
+ process.on('SIGTERM', forward);
645
+ child.on('error', (err) => {
646
+ io.stderr.write(`pactive: failed to launch ${host}: ${err.message}\n`);
647
+ resolve(err.code === 'ENOENT' ? 127 : 1);
648
+ });
649
+ child.on('exit', (code, signal) => {
650
+ process.off('SIGINT', forward);
651
+ process.off('SIGTERM', forward);
652
+ resolve(signal ? 128 + (os.constants.signals[signal] ?? 0) : (code ?? 0));
653
+ });
654
+ });
655
+ });
656
+ }
657
+
658
+ export async function main(argv, env = process.env, io = { stdout: process.stdout, stderr: process.stderr }) {
659
+ const args = argv.slice(2);
660
+ if (args.length === 0) {
661
+ usage(io.stderr);
662
+ return 2;
663
+ }
664
+ const [cmd, ...rest] = args;
665
+ switch (cmd) {
666
+ case '--version':
667
+ case '-v':
668
+ io.stdout.write(`${pactiveVersion()}\n`);
669
+ return 0;
670
+ case '--help':
671
+ case '-h':
672
+ // Explicit help goes to stdout: it is the requested output, not host noise
673
+ // (I37 protects the stdout of a *spawned host*; `--help | head -8` must be greppable).
674
+ usage(io.stdout);
675
+ return 0;
676
+ case '--print-plan':
677
+ return printPlan(rest, env, io);
678
+ case 'doctor':
679
+ return doctorCmd(rest, env, io);
680
+ case 'config':
681
+ return rest[0] === 'set' ? configSetCmd(rest.slice(1), env, io) : configGetCmd(rest[0] === 'get' ? rest.slice(1) : rest, env, io);
682
+ case 'schedule':
683
+ case 'tasks':
684
+ case 'cancel':
685
+ return { schedule: scheduleCmd, tasks: tasksCmd, cancel: cancelCmd }[cmd](rest, env, io);
686
+ case 'install':
687
+ return installCmd(rest, env, io, 'install');
688
+ case 'uninstall':
689
+ return installCmd(rest, env, io, 'uninstall');
690
+ default:
691
+ break;
692
+ }
693
+ if (!HOSTS.includes(cmd)) {
694
+ unknownHost(cmd, io.stderr);
695
+ return 2;
696
+ }
697
+ return runHost(cmd, rest, env, io);
698
+ }
699
+
700
+ // Entry guard: compare REAL paths. Installed bin shims and macOS /tmp are symlinks, so
701
+ // `process.argv[1]` and `import.meta.url` differ textually when they point at the same file —
702
+ // a plain string compare silently turns the CLI into a no-op (defect found 2026-11-05 via install chain).
703
+ const isMain = (() => {
704
+ try {
705
+ const self = realpathSync(fileURLToPath(import.meta.url));
706
+ const argv1 = realpathSync(process.argv[1]);
707
+ return self === argv1;
708
+ } catch {
709
+ return false;
710
+ }
711
+ })();
712
+ if (isMain) {
713
+ try {
714
+ process.exitCode = await main(process.argv, process.env, { stdout: process.stdout, stderr: process.stderr });
715
+ } catch (err) {
716
+ const exitCode = err instanceof PactiveError ? err.exitCode : 1;
717
+ process.stderr.write(`pactive: ${err?.message ?? err}\n`);
718
+ process.exitCode = exitCode;
719
+ }
720
+ }