pactive 0.0.0-stage → 0.0.2-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,439 @@
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(io) {
22
+ io.write(`pactive — proactive companion for Pi / OPCteam / DSH
23
+
24
+ Usage:
25
+ pactive <host> [args...] run a host with pactive injected (stdio inherited)
26
+ pactive --print-plan <host> print the launch plan as JSON (no side effects)
27
+ pactive doctor [--json] diagnose hosts, state root and permissions (read-only)
28
+ pactive install <host> register pactive with a host (idempotent)
29
+ pactive uninstall <host> unregister pactive and remove pactive state residue
30
+ pactive --version | -v print the pactive version
31
+ pactive --help | -h show this help
32
+
33
+ Hosts: ${HOSTS.join(', ')}
34
+
35
+ Environment:
36
+ PACTIVE_HOME package root override (default: this package)
37
+ PACTIVE_STATE_DIR pactive state root override
38
+ PACTIVE_PI_BIN override the pi executable
39
+ PACTIVE_OPCTEAM_BIN override the opcteam executable
40
+ PACTIVE_DSH_BIN override the dsh executable
41
+ `);
42
+ }
43
+
44
+ function unknownHost(host, io) {
45
+ io.write(`pactive: unknown host '${host}'\n`);
46
+ io.write(`pactive: known hosts: ${HOSTS.join(', ')}\n`);
47
+ }
48
+
49
+ function resolvePkgRoot(env) {
50
+ return env.PACTIVE_HOME ? path.resolve(env.PACTIVE_HOME) : PKG_ROOT;
51
+ }
52
+
53
+ function pactiveVersion() {
54
+ try {
55
+ const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
56
+ if (typeof pkg.version === 'string') return pkg.version;
57
+ } catch {
58
+ // package.json not built yet — fall back to the contract version
59
+ }
60
+ return FALLBACK_VERSION;
61
+ }
62
+
63
+ async function loadAdapter(host) {
64
+ return import(new URL(`../src/hosts/${host}/index.mjs`, import.meta.url));
65
+ }
66
+
67
+ /** src/core is built by the pactive-core worker; until then the CLI degrades instead of faking it. */
68
+ async function loadCore() {
69
+ try {
70
+ return await import(new URL('../src/core/index.mjs', import.meta.url));
71
+ } catch (err) {
72
+ throw new PactiveError('PACTIVE core not built yet', { code: 'CORE_MISSING', exitCode: 1, cause: err });
73
+ }
74
+ }
75
+
76
+ async function resolveStateRoot(env, warnings = []) {
77
+ if (env.PACTIVE_STATE_DIR) return path.resolve(env.PACTIVE_STATE_DIR);
78
+ try {
79
+ const core = await loadCore();
80
+ const paths = core.resolvePaths('pi', env);
81
+ if (paths?.root) return paths.root;
82
+ warnings.push('PACTIVE core returned no state root — using the default location');
83
+ } catch (err) {
84
+ warnings.push(`${err.message} — using the default state root`);
85
+ }
86
+ return path.join(os.homedir(), '.pactive');
87
+ }
88
+
89
+ function modeOf(p) {
90
+ return (statSync(p).mode & 0o777).toString(8).padStart(4, '0');
91
+ }
92
+
93
+ function probeVersion(exec) {
94
+ const res = spawnSync(exec, ['--version'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
95
+ const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.trim();
96
+ if (!out) return null;
97
+ return out.split(/\s+/)[0];
98
+ }
99
+
100
+ function majorOf(version) {
101
+ const major = Number.parseInt(String(version).split('.')[0], 10);
102
+ return Number.isNaN(major) ? null : major;
103
+ }
104
+
105
+ function isCompatible(found, supported) {
106
+ if (!found || !supported) return false;
107
+ const a = majorOf(found);
108
+ const b = majorOf(supported);
109
+ return a !== null && b !== null && a === b;
110
+ }
111
+
112
+ function countFiles(dir) {
113
+ if (!existsSync(dir)) return 0;
114
+ let n = 0;
115
+ for (const name of readdirSync(dir)) {
116
+ const abs = path.join(dir, name);
117
+ if (statSync(abs).isDirectory()) n += countFiles(abs);
118
+ else n += 1;
119
+ }
120
+ return n;
121
+ }
122
+
123
+ function stateStats(stateRoot) {
124
+ const stats = {
125
+ exists: existsSync(stateRoot),
126
+ perm: { dir: null, file: null },
127
+ idle: { enabled: false },
128
+ sessions: 0,
129
+ tasks: 0,
130
+ outbox: 0,
131
+ locksStale: 0,
132
+ fails: [],
133
+ };
134
+ if (!stats.exists) return stats;
135
+
136
+ stats.perm.dir = modeOf(stateRoot);
137
+ if (stats.perm.dir !== '0700') {
138
+ stats.fails.push(`state root ${stateRoot} has mode ${stats.perm.dir}, expected 0700`);
139
+ }
140
+
141
+ const stateFile = path.join(stateRoot, 'state.json');
142
+ if (existsSync(stateFile)) {
143
+ stats.perm.file = modeOf(stateFile);
144
+ if (stats.perm.file !== '0600') {
145
+ stats.fails.push(`state file ${stateFile} has mode ${stats.perm.file}, expected 0600`);
146
+ }
147
+ try {
148
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'));
149
+ stats.idle.enabled = Boolean(state?.idle?.enabled);
150
+ if (Array.isArray(state?.tasks)) stats.tasks = state.tasks.length;
151
+ } catch {
152
+ stats.fails.push(`state file ${stateFile} is not valid JSON`);
153
+ }
154
+ }
155
+
156
+ const tasksFile = path.join(stateRoot, 'tasks.json');
157
+ if (existsSync(tasksFile)) {
158
+ try {
159
+ const tasks = JSON.parse(readFileSync(tasksFile, 'utf8'));
160
+ if (Array.isArray(tasks)) stats.tasks = tasks.length;
161
+ } catch {
162
+ stats.fails.push(`tasks file ${tasksFile} is not valid JSON`);
163
+ }
164
+ }
165
+
166
+ stats.sessions = countFiles(path.join(stateRoot, 'peers'));
167
+ stats.outbox = countFiles(path.join(stateRoot, 'outbox'));
168
+
169
+ const locksDir = path.join(stateRoot, 'locks');
170
+ if (existsSync(locksDir)) {
171
+ const now = Date.now();
172
+ for (const name of readdirSync(locksDir)) {
173
+ const abs = path.join(locksDir, name);
174
+ if (statSync(abs).isFile() && now - statSync(abs).mtimeMs > STALE_LOCK_MS) stats.locksStale += 1;
175
+ }
176
+ }
177
+ return stats;
178
+ }
179
+
180
+ async function doctorReport(env) {
181
+ const warnings = [];
182
+ const stateRoot = await resolveStateRoot(env, warnings);
183
+ const hosts = [];
184
+ for (const host of HOSTS) {
185
+ const mod = await loadAdapter(host);
186
+ let present = false;
187
+ let version = null;
188
+ let compatible = false;
189
+ try {
190
+ const exec = mod.resolveExec({ env });
191
+ present = true;
192
+ version = probeVersion(exec);
193
+ compatible = isCompatible(version, mod.SUPPORTED_VERSION);
194
+ if (!version) {
195
+ warnings.push(`host ${host}: installed but its version could not be read`);
196
+ } else if (!compatible) {
197
+ warnings.push(`host ${host}: version ${version} differs from the supported ${mod.SUPPORTED_VERSION}`);
198
+ }
199
+ } catch (err) {
200
+ if (err.code !== 'HOST_MISSING') throw err;
201
+ warnings.push(`host ${host}: not installed (set PACTIVE_${host.toUpperCase()}_BIN to override)`);
202
+ }
203
+ hosts.push({ host, present, version, mode: mod.MODE, compatible });
204
+ }
205
+
206
+ const stats = stateStats(stateRoot);
207
+ if (!stats.exists) warnings.push(`state root ${stateRoot} does not exist yet`);
208
+ for (const fail of stats.fails) warnings.push(`FAIL: ${fail}`);
209
+
210
+ return {
211
+ ok: stats.fails.length === 0,
212
+ hosts,
213
+ stateRoot,
214
+ perm: stats.perm,
215
+ idle: { enabled: stats.idle.enabled, source: 'core-timeline' },
216
+ sessions: stats.sessions,
217
+ tasks: stats.tasks,
218
+ outbox: stats.outbox,
219
+ locksStale: stats.locksStale,
220
+ secrets: { credentialsRead: false },
221
+ warnings,
222
+ };
223
+ }
224
+
225
+ function printPlan(args, env, io) {
226
+ const host = args[0];
227
+ if (!host) {
228
+ unknownHost('', io.stderr);
229
+ return Promise.resolve(2);
230
+ }
231
+ if (!HOSTS.includes(host)) {
232
+ unknownHost(host, io.stderr);
233
+ return Promise.resolve(2);
234
+ }
235
+ return loadAdapter(host).then((mod) => {
236
+ const plan = mod.plan({ pkgRoot: resolvePkgRoot(env), env, userArgs: [] });
237
+ io.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
238
+ return 0;
239
+ });
240
+ }
241
+
242
+ function doctorCmd(args, env, io) {
243
+ const asJson = args.includes('--json');
244
+ return doctorReport(env).then((report) => {
245
+ if (asJson) {
246
+ io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
247
+ } else {
248
+ io.stderr.write(`pactive doctor: ${report.ok ? 'OK' : 'FAIL'}\n`);
249
+ io.stderr.write(`state root: ${report.stateRoot} (dir ${report.perm.dir}, file ${report.perm.file})\n`);
250
+ for (const h of report.hosts) {
251
+ io.stderr.write(
252
+ `host ${h.host}: present=${h.present} version=${h.version ?? 'unknown'} mode=${h.mode} compatible=${h.compatible}\n`,
253
+ );
254
+ }
255
+ io.stderr.write(
256
+ `sessions=${report.sessions} tasks=${report.tasks} outbox=${report.outbox} locksStale=${report.locksStale} idle.enabled=${report.idle.enabled}\n`,
257
+ );
258
+ for (const w of report.warnings) io.stderr.write(`- ${w}\n`);
259
+ }
260
+ return report.ok ? 0 : 1;
261
+ });
262
+ }
263
+
264
+ function runHostCommand(exec, args) {
265
+ const res = spawnSync(exec, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
266
+ return { status: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
267
+ }
268
+
269
+ function verifyRegistered(mod, exec, pkgRoot) {
270
+ const res = runHostCommand(exec, mod.verifyArgs({ pkgRoot }));
271
+ return mod.verifyMatch({ pkgRoot }, `${res.stdout}\n${res.stderr}`);
272
+ }
273
+
274
+ const OWNED_STATE_ENTRIES = ['state.json', 'tasks.json', 'ledger.jsonl', 'peers', 'inbox', 'outbox', 'locks'];
275
+
276
+ function removeStateResidue(stateRoot) {
277
+ if (!existsSync(stateRoot)) return [];
278
+ for (const name of OWNED_STATE_ENTRIES) {
279
+ rmSync(path.join(stateRoot, name), { recursive: true, force: true });
280
+ }
281
+ for (const name of readdirSync(stateRoot)) {
282
+ if (/^(state|tasks)\.json\.corrupt-/.test(name)) {
283
+ rmSync(path.join(stateRoot, name), { recursive: true, force: true });
284
+ }
285
+ }
286
+ const left = [];
287
+ const walk = (dir, rel) => {
288
+ for (const name of readdirSync(dir)) {
289
+ const abs = path.join(dir, name);
290
+ const relPath = rel ? path.join(rel, name) : name;
291
+ if (statSync(abs).isDirectory()) walk(abs, relPath);
292
+ else left.push(relPath);
293
+ }
294
+ };
295
+ walk(stateRoot, '');
296
+ return left;
297
+ }
298
+
299
+ function installCmd(args, env, io, action) {
300
+ const host = args[0];
301
+ if (!host) {
302
+ unknownHost('', io.stderr);
303
+ return Promise.resolve(2);
304
+ }
305
+ if (!HOSTS.includes(host)) {
306
+ unknownHost(host, io.stderr);
307
+ return Promise.resolve(2);
308
+ }
309
+ return loadAdapter(host).then(async (mod) => {
310
+ const exec = mod.resolveExec({ env });
311
+ const pkgRoot = resolvePkgRoot(env);
312
+ if (action === 'uninstall' && typeof mod.isInstalled === 'function') {
313
+ let installed = true;
314
+ try { installed = mod.isInstalled({ pkgRoot, env }); } catch { installed = true; }
315
+ if (!installed) {
316
+ // 幂等:没装就什么都不跑(跑宿主 remove 会让 DSH 重建 profile,制造"删了又出现"的怪象)
317
+ if (typeof mod.cleanup === 'function') {
318
+ try { mod.cleanup({ pkgRoot, env }); } catch { /* residue 清理失败由下方 residue 检查兜底 */ }
319
+ }
320
+ io.stderr.write(`pactive: ${host} not installed — nothing to uninstall\n`);
321
+ return 0;
322
+ }
323
+ }
324
+ const argsFor = action === 'install' ? mod.installArgs({ pkgRoot }) : mod.uninstallArgs({ pkgRoot });
325
+ const res = runHostCommand(exec, argsFor);
326
+
327
+ if (res.status !== 0) {
328
+ const stillRegistered = verifyRegistered(mod, exec, pkgRoot);
329
+ if (action === 'install' ? !stillRegistered : stillRegistered) {
330
+ io.stderr.write(`pactive: ${action} ${host} failed (exit ${res.status})\n`);
331
+ if (res.stderr.trim()) io.stderr.write(`${res.stderr.trim()}\n`);
332
+ return 1;
333
+ }
334
+ }
335
+
336
+ if (action === 'uninstall') {
337
+ // Host-level residue: adapters may own resources Pactive created (e.g. the DSH profile dir).
338
+ if (typeof mod.cleanup === 'function') {
339
+ try {
340
+ const cleaned = mod.cleanup({ pkgRoot, env });
341
+ if (cleaned && cleaned.removed) io.stderr.write(`pactive: removed ${cleaned.path}\n`);
342
+ } catch (err) {
343
+ io.stderr.write(`pactive: cleanup warning: ${err?.message ?? err}\n`);
344
+ }
345
+ }
346
+ const stateRoot = await resolveStateRoot(env, []);
347
+ const residue = removeStateResidue(stateRoot);
348
+ if (residue.length > 0) {
349
+ io.stderr.write(`pactive: residue left in ${stateRoot}: ${residue.join(', ')}\n`);
350
+ return 1;
351
+ }
352
+ }
353
+
354
+ io.stderr.write(`pactive: ${action} ${host} ok (${pkgRoot})\n`);
355
+ return 0;
356
+ });
357
+ }
358
+
359
+ function runHost(host, userArgs, env, io) {
360
+ return loadAdapter(host).then((mod) => {
361
+ const exec = mod.resolveExec({ env });
362
+ const pkgRoot = resolvePkgRoot(env);
363
+ const argv = mod.buildArgv({ pkgRoot, userArgs });
364
+ io.stderr.write(`pactive: launching ${host} (${exec})\n`);
365
+ return new Promise((resolve) => {
366
+ const child = spawn(exec, argv, { stdio: 'inherit', env });
367
+ const forward = (sig) => {
368
+ if (!child.killed) child.kill(sig);
369
+ };
370
+ process.on('SIGINT', forward);
371
+ process.on('SIGTERM', forward);
372
+ child.on('error', (err) => {
373
+ io.stderr.write(`pactive: failed to launch ${host}: ${err.message}\n`);
374
+ resolve(err.code === 'ENOENT' ? 127 : 1);
375
+ });
376
+ child.on('exit', (code, signal) => {
377
+ process.off('SIGINT', forward);
378
+ process.off('SIGTERM', forward);
379
+ resolve(signal ? 128 + (os.constants.signals[signal] ?? 0) : (code ?? 0));
380
+ });
381
+ });
382
+ });
383
+ }
384
+
385
+ export async function main(argv, env = process.env, io = { stdout: process.stdout, stderr: process.stderr }) {
386
+ const args = argv.slice(2);
387
+ if (args.length === 0) {
388
+ usage(io.stderr);
389
+ return 2;
390
+ }
391
+ const [cmd, ...rest] = args;
392
+ switch (cmd) {
393
+ case '--version':
394
+ case '-v':
395
+ io.stdout.write(`${pactiveVersion()}\n`);
396
+ return 0;
397
+ case '--help':
398
+ case '-h':
399
+ usage(io.stderr);
400
+ return 0;
401
+ case '--print-plan':
402
+ return printPlan(rest, env, io);
403
+ case 'doctor':
404
+ return doctorCmd(rest, env, io);
405
+ case 'install':
406
+ return installCmd(rest, env, io, 'install');
407
+ case 'uninstall':
408
+ return installCmd(rest, env, io, 'uninstall');
409
+ default:
410
+ break;
411
+ }
412
+ if (!HOSTS.includes(cmd)) {
413
+ unknownHost(cmd, io.stderr);
414
+ return 2;
415
+ }
416
+ return runHost(cmd, rest, env, io);
417
+ }
418
+
419
+ // Entry guard: compare REAL paths. Installed bin shims and macOS /tmp are symlinks, so
420
+ // `process.argv[1]` and `import.meta.url` differ textually when they point at the same file —
421
+ // a plain string compare silently turns the CLI into a no-op (defect found 2026-11-05 via install chain).
422
+ const isMain = (() => {
423
+ try {
424
+ const self = realpathSync(fileURLToPath(import.meta.url));
425
+ const argv1 = realpathSync(process.argv[1]);
426
+ return self === argv1;
427
+ } catch {
428
+ return false;
429
+ }
430
+ })();
431
+ if (isMain) {
432
+ try {
433
+ process.exitCode = await main(process.argv, process.env, { stdout: process.stdout, stderr: process.stderr });
434
+ } catch (err) {
435
+ const exitCode = err instanceof PactiveError ? err.exitCode : 1;
436
+ process.stderr.write(`pactive: ${err?.message ?? err}\n`);
437
+ process.exitCode = exitCode;
438
+ }
439
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ description: Pactive 主动机制 — status、schedule、tell、history(经 pactive MCP server)
3
+ argument-hint: "[status|schedule|tell|history] [args...]"
4
+ ---
5
+
6
+ # pactive:pactive
7
+
8
+ 把用户关于「主动机制 / 空闲触发 / 定时任务 / 跨会话消息」的请求路由到 `pactive` MCP server。
9
+ 宿主把它暴露为 `/pactive:pactive`;裸 `/pactive` 不由插件契约保证。
10
+
11
+ ## 工具名
12
+
13
+ OPCteam 用命名空间暴露插件 MCP 工具:
14
+
15
+ ```
16
+ mcp__plugin_pactive_pactive__<tool>
17
+ ```
18
+
19
+ 具体名字:
20
+
21
+ - `mcp__plugin_pactive_pactive__pactive_status`
22
+ - `mcp__plugin_pactive_pactive__pactive_schedule`
23
+ - `mcp__plugin_pactive_pactive__pactive_tell`
24
+ - `mcp__plugin_pactive_pactive__pactive_history`
25
+
26
+ 裸名(`pactive_status`)不一定能被 ToolSearch 发现;若命名空间工具没加载,用
27
+ `select:mcp__plugin_pactive_pactive__pactive_status` 搜一次。
28
+
29
+ ## 路由
30
+
31
+ | 用户说 | 调用 |
32
+ |---|---|
33
+ | 状态 / 为什么没触发 | `pactive_status`(看 `idle.reason`,别猜) |
34
+ | 10 分钟后提醒我 / 定时 | `pactive_schedule` `when:"10m"` `prompt:"…"` |
35
+ | 18:30 提醒我 | `pactive_schedule` `when:"18:30"` |
36
+ | 每小时 | `pactive_schedule` `when:"every 1h"` |
37
+ | 告诉另一个会话 | `pactive_tell` `to` `body`(可选 `kind:"ask"`) |
38
+ | 最近发生过什么 | `pactive_history` `limit` |
39
+
40
+ ## 硬规则
41
+
42
+ 1. **不要替用户改配置**:pactive 的开关与配置是人工闸门(CLI `--confirm`)。用户要求改配置时,
43
+ 告诉他用 `pactive config set … --confirm`,不要自行绕过。
44
+ 2. **回执如实转述**:`queued` 就是**还没送到**(对端离线),不要说成已送达。
45
+ 3. **默认不打扰**:空闲触发默认关闭;用户没明确要「空闲自动推进」时,不要假设它已开启。
46
+ 4. **只报新事实**:状态类输出要给出 `idle.reason` 与护栏结论,不要泛泛说「一切正常」。
@@ -0,0 +1,10 @@
1
+ # DSH bundle overlay for the pactive package.
2
+ #
3
+ # Format (verified against DSH 0.2.1-rc.5 by running
4
+ # `dsh plugin --profile <p> add <pkg>`): a **top-level YAML array** of loader
5
+ # patch entries; each entry may carry an `insert` list of plugin rows
6
+ # ({ id, name, config }). `name` must be the installed package name (`pactive`).
7
+ - insert:
8
+ - id: pactive
9
+ name: pactive/dsh
10
+ config: {}