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.
- package/.opcteam-plugin/plugin.json +12 -0
- package/BREAKING-CHANGES.md +55 -0
- package/LICENSE +14 -0
- package/README.md +172 -2
- package/agents/pactive-observer.md +35 -0
- package/bin/pactive.mjs +439 -0
- package/commands/pactive.md +46 -0
- package/dsh/cordis.patch.yml +10 -0
- package/dsh/index.js +285 -0
- package/hooks/hooks.json +54 -0
- package/hooks/post-tool-use.mjs +19 -0
- package/hooks/session-end.mjs +19 -0
- package/hooks/session-start.mjs +20 -0
- package/hooks/stop.mjs +55 -0
- package/hooks/user-prompt-submit.mjs +34 -0
- package/mcp/server.mjs +155 -0
- package/mcp/tools/_core.mjs +254 -0
- package/mcp/tools/definitions.mjs +173 -0
- package/package.json +51 -4
- package/pi/extension.ts +420 -0
- package/pi/skills/pactive/SKILL.md +47 -0
- package/skills/pactive/SKILL.md +47 -0
- package/src/core/activity.mjs +67 -0
- package/src/core/config.mjs +84 -0
- package/src/core/doctor.mjs +143 -0
- package/src/core/errors.mjs +8 -0
- package/src/core/guard.mjs +95 -0
- package/src/core/identity.mjs +35 -0
- package/src/core/idle.mjs +53 -0
- package/src/core/index.mjs +13 -0
- package/src/core/lineage.mjs +72 -0
- package/src/core/lock.mjs +71 -0
- package/src/core/mailbox.mjs +162 -0
- package/src/core/paths.mjs +47 -0
- package/src/core/scheduler.mjs +89 -0
- package/src/core/state.mjs +189 -0
- package/src/hosts/dsh/index.mjs +128 -0
- package/src/hosts/errors.mjs +24 -0
- package/src/hosts/opcteam/index.mjs +77 -0
- package/src/hosts/pi/index.mjs +82 -0
- package/src/hosts/shared/index.mjs +64 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { pactiveError } from './errors.mjs';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_ROOT_DIRNAME = '.pactive';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the state root and every file location derived from it.
|
|
11
|
+
* The root is shared by all hosts: peers, inboxes and outboxes are cross-host.
|
|
12
|
+
*/
|
|
13
|
+
export function resolvePaths(hostKind, env = process.env) {
|
|
14
|
+
const kind = typeof hostKind === 'string' ? hostKind.trim() : '';
|
|
15
|
+
if (!kind) throw pactiveError('HOST_KIND_REQUIRED', 'hostKind is required');
|
|
16
|
+
|
|
17
|
+
const source = env && typeof env.PACTIVE_STATE_DIR === 'string' ? env.PACTIVE_STATE_DIR.trim() : '';
|
|
18
|
+
const root = path.resolve(source || path.join(os.homedir(), DEFAULT_ROOT_DIRNAME));
|
|
19
|
+
|
|
20
|
+
let stat = null;
|
|
21
|
+
try {
|
|
22
|
+
stat = fs.statSync(root);
|
|
23
|
+
} catch {
|
|
24
|
+
stat = null;
|
|
25
|
+
}
|
|
26
|
+
if (stat) {
|
|
27
|
+
if (!stat.isDirectory()) {
|
|
28
|
+
throw pactiveError('STATE_ROOT_INVALID', `state root is not a directory: ${root}`);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
fs.accessSync(root, fs.constants.W_OK);
|
|
32
|
+
} catch {
|
|
33
|
+
throw pactiveError('STATE_ROOT_NOT_WRITABLE', `state root is not writable: ${root}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
root,
|
|
39
|
+
stateFile: path.join(root, 'state.json'),
|
|
40
|
+
tasksFile: path.join(root, 'tasks.json'),
|
|
41
|
+
peersDir: path.join(root, 'peers'),
|
|
42
|
+
inboxDir: path.join(root, 'inbox'),
|
|
43
|
+
outboxDir: path.join(root, 'outbox'),
|
|
44
|
+
ledgerFile: path.join(root, 'ledger.jsonl'),
|
|
45
|
+
locksDir: path.join(root, 'locks'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { pactiveError } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
export const TASK_KINDS = ['in', 'at', 'every'];
|
|
4
|
+
|
|
5
|
+
let seq = 0;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Task table: "in" (delay), "at" (absolute time) and "every" (repeating).
|
|
9
|
+
* Tasks live in the persisted state, so a restart recovers them.
|
|
10
|
+
*/
|
|
11
|
+
export const schedule = {
|
|
12
|
+
add(state, spec, now = Date.now()) {
|
|
13
|
+
if (!spec || typeof spec !== 'object') throw pactiveError('TASK_SPEC_REQUIRED', 'a task spec is required');
|
|
14
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
15
|
+
const kind = spec.kind ?? (Number.isFinite(spec.at) ? 'at' : 'in');
|
|
16
|
+
if (!TASK_KINDS.includes(kind)) {
|
|
17
|
+
throw pactiveError('TASK_KIND_UNKNOWN', `unknown task kind: ${kind}`);
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(state.tasks)) state.tasks = [];
|
|
20
|
+
|
|
21
|
+
let dueAt;
|
|
22
|
+
let everyMs = 0;
|
|
23
|
+
if (kind === 'at') {
|
|
24
|
+
if (!Number.isFinite(spec.at)) throw pactiveError('TASK_AT_REQUIRED', 'an "at" task needs spec.at');
|
|
25
|
+
dueAt = spec.at;
|
|
26
|
+
} else if (kind === 'every') {
|
|
27
|
+
everyMs = Number.isFinite(spec.everyMs) ? spec.everyMs : Number.isFinite(spec.intervalMs) ? spec.intervalMs : 0;
|
|
28
|
+
if (everyMs <= 0) throw pactiveError('TASK_INTERVAL_REQUIRED', 'an "every" task needs a positive interval');
|
|
29
|
+
dueAt = ts + everyMs;
|
|
30
|
+
} else {
|
|
31
|
+
const delay = Number.isFinite(spec.delayMs) ? spec.delayMs
|
|
32
|
+
: Number.isFinite(spec.afterMs) ? spec.afterMs
|
|
33
|
+
: Number.isFinite(spec.ms) ? spec.ms : 0;
|
|
34
|
+
dueAt = ts + delay;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const owner = spec.owner ?? state.identity?.ownerKey ?? '';
|
|
38
|
+
const id = spec.id ?? `task-${ts}-${(seq += 1)}`;
|
|
39
|
+
|
|
40
|
+
const existing = state.tasks.find((t) => t.id === id && t.owner === owner && t.dueAt === dueAt && t.active !== false);
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
|
|
43
|
+
const task = {
|
|
44
|
+
id,
|
|
45
|
+
kind,
|
|
46
|
+
dueAt,
|
|
47
|
+
owner,
|
|
48
|
+
everyMs,
|
|
49
|
+
label: spec.label ?? null,
|
|
50
|
+
payload: spec.payload ?? null,
|
|
51
|
+
createdAt: ts,
|
|
52
|
+
lastRunAt: 0,
|
|
53
|
+
active: true,
|
|
54
|
+
expired: kind === 'at' && dueAt <= ts,
|
|
55
|
+
};
|
|
56
|
+
state.tasks.push(task);
|
|
57
|
+
return task;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
cancel(state, id) {
|
|
61
|
+
if (!Array.isArray(state.tasks)) return false;
|
|
62
|
+
const before = state.tasks.length;
|
|
63
|
+
state.tasks = state.tasks.filter((t) => t.id !== id);
|
|
64
|
+
return state.tasks.length !== before;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
list(state) {
|
|
68
|
+
return Array.isArray(state.tasks) ? [...state.tasks] : [];
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/** Returns the tasks that are due now and consumes them (once for "in"/"at"). */
|
|
72
|
+
due(state, now = Date.now()) {
|
|
73
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
74
|
+
if (!Array.isArray(state.tasks)) return [];
|
|
75
|
+
const due = [];
|
|
76
|
+
for (const task of state.tasks) {
|
|
77
|
+
if (task.active === false) continue;
|
|
78
|
+
if (!(task.dueAt <= ts)) continue;
|
|
79
|
+
due.push({ ...task });
|
|
80
|
+
task.lastRunAt = ts;
|
|
81
|
+
if (task.kind === 'every') {
|
|
82
|
+
task.dueAt = ts + (task.everyMs || 0);
|
|
83
|
+
} else {
|
|
84
|
+
task.active = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return due;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { pactiveError } from './errors.mjs';
|
|
5
|
+
import { ensureDir, withLock } from './lock.mjs';
|
|
6
|
+
import { ledger } from './lineage.mjs';
|
|
7
|
+
|
|
8
|
+
export const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
let tmpSeq = 0;
|
|
11
|
+
|
|
12
|
+
export function defaultState() {
|
|
13
|
+
return {
|
|
14
|
+
schemaVersion: SCHEMA_VERSION,
|
|
15
|
+
identity: { agentId: '', ownerKey: '', projectHash: '' },
|
|
16
|
+
busy: false,
|
|
17
|
+
controlReturned: false,
|
|
18
|
+
lastActivityAt: 0,
|
|
19
|
+
lastUserActivityAt: 0,
|
|
20
|
+
lastAgentActivityAt: 0,
|
|
21
|
+
epoch: 0,
|
|
22
|
+
idle: { enabled: false, thresholdMs: 900000 },
|
|
23
|
+
policy: {
|
|
24
|
+
quietStart: null,
|
|
25
|
+
quietEnd: null,
|
|
26
|
+
hourlyLimit: 6,
|
|
27
|
+
minIntervalMs: 600000,
|
|
28
|
+
cooldownMs: 1800000,
|
|
29
|
+
},
|
|
30
|
+
// lastTriggerEpoch is the extra field the frozen shape does not carry: the
|
|
31
|
+
// "one trigger per activity epoch" rule needs the epoch of the last trigger.
|
|
32
|
+
guard: { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 },
|
|
33
|
+
tasks: [],
|
|
34
|
+
peers: {},
|
|
35
|
+
outbox: [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Accept either a paths object (from resolvePaths) or a state file path. */
|
|
40
|
+
export function toPaths(target) {
|
|
41
|
+
if (!target) throw pactiveError('STATE_TARGET_REQUIRED', 'a paths object or state file path is required');
|
|
42
|
+
if (typeof target === 'string') {
|
|
43
|
+
const stateFile = path.resolve(target);
|
|
44
|
+
const root = path.dirname(stateFile);
|
|
45
|
+
return {
|
|
46
|
+
root,
|
|
47
|
+
stateFile,
|
|
48
|
+
tasksFile: path.join(root, 'tasks.json'),
|
|
49
|
+
peersDir: path.join(root, 'peers'),
|
|
50
|
+
inboxDir: path.join(root, 'inbox'),
|
|
51
|
+
outboxDir: path.join(root, 'outbox'),
|
|
52
|
+
ledgerFile: path.join(root, 'ledger.jsonl'),
|
|
53
|
+
locksDir: path.join(root, 'locks'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (typeof target.stateFile !== 'string' || !target.stateFile) {
|
|
57
|
+
throw pactiveError('STATE_TARGET_REQUIRED', 'a paths object or state file path is required');
|
|
58
|
+
}
|
|
59
|
+
const root = target.root ?? path.dirname(target.stateFile);
|
|
60
|
+
return {
|
|
61
|
+
root,
|
|
62
|
+
stateFile: target.stateFile,
|
|
63
|
+
tasksFile: target.tasksFile ?? path.join(root, 'tasks.json'),
|
|
64
|
+
peersDir: target.peersDir ?? path.join(root, 'peers'),
|
|
65
|
+
inboxDir: target.inboxDir ?? path.join(root, 'inbox'),
|
|
66
|
+
outboxDir: target.outboxDir ?? path.join(root, 'outbox'),
|
|
67
|
+
ledgerFile: target.ledgerFile ?? path.join(root, 'ledger.jsonl'),
|
|
68
|
+
locksDir: target.locksDir ?? path.join(root, 'locks'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function ensureStateDirs(paths) {
|
|
73
|
+
ensureDir(paths.root);
|
|
74
|
+
ensureDir(paths.locksDir);
|
|
75
|
+
ensureDir(paths.peersDir);
|
|
76
|
+
ensureDir(paths.inboxDir);
|
|
77
|
+
ensureDir(paths.outboxDir);
|
|
78
|
+
return paths;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalize(raw) {
|
|
82
|
+
const base = defaultState();
|
|
83
|
+
const state = { ...base, ...raw };
|
|
84
|
+
state.schemaVersion = SCHEMA_VERSION;
|
|
85
|
+
state.identity = { ...base.identity, ...(raw.identity || {}) };
|
|
86
|
+
state.idle = { ...base.idle, ...(raw.idle || {}) };
|
|
87
|
+
state.policy = { ...base.policy, ...(raw.policy || {}) };
|
|
88
|
+
state.guard = { ...base.guard, ...(raw.guard || {}) };
|
|
89
|
+
if (!Number.isFinite(state.guard.lastTriggerEpoch)) state.guard.lastTriggerEpoch = -1;
|
|
90
|
+
state.tasks = Array.isArray(raw.tasks) ? raw.tasks : [];
|
|
91
|
+
state.peers = raw.peers && typeof raw.peers === 'object' ? raw.peers : {};
|
|
92
|
+
state.outbox = Array.isArray(raw.outbox) ? raw.outbox : [];
|
|
93
|
+
state.busy = raw.busy === true;
|
|
94
|
+
state.controlReturned = raw.controlReturned === true;
|
|
95
|
+
state.epoch = Number.isFinite(raw.epoch) ? raw.epoch : 0;
|
|
96
|
+
for (const key of ['lastActivityAt', 'lastUserActivityAt', 'lastAgentActivityAt']) {
|
|
97
|
+
state[key] = Number.isFinite(raw[key]) ? raw[key] : 0;
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function loadState(target) {
|
|
103
|
+
const paths = toPaths(target);
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = fs.readFileSync(paths.stateFile, 'utf8');
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.code === 'ENOENT') return defaultState();
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
if (!parsed || typeof parsed !== 'object' || parsed.schemaVersion !== SCHEMA_VERSION) {
|
|
114
|
+
throw pactiveError('STATE_SCHEMA_UNSUPPORTED', `unsupported schemaVersion in ${paths.stateFile}`);
|
|
115
|
+
}
|
|
116
|
+
return normalize(parsed);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
quarantine(paths, err);
|
|
119
|
+
return defaultState();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function quarantine(paths, cause) {
|
|
124
|
+
const stamp = Date.now();
|
|
125
|
+
const target = `${paths.stateFile}.corrupt-${stamp}`;
|
|
126
|
+
try {
|
|
127
|
+
fs.renameSync(paths.stateFile, target);
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
ledger.append(paths.ledgerFile, {
|
|
133
|
+
event: 'state.corrupt',
|
|
134
|
+
reason: cause && cause.code ? cause.code : 'parse-error',
|
|
135
|
+
file: 'state.json',
|
|
136
|
+
quarantined: path.basename(target),
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// the ledger must never block the host
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function saveState(target, state) {
|
|
144
|
+
const paths = toPaths(target);
|
|
145
|
+
if (!state || typeof state !== 'object') {
|
|
146
|
+
throw pactiveError('STATE_REQUIRED', 'a state object is required');
|
|
147
|
+
}
|
|
148
|
+
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
149
|
+
throw pactiveError('STATE_SCHEMA_UNSUPPORTED', `expected schemaVersion ${SCHEMA_VERSION}`);
|
|
150
|
+
}
|
|
151
|
+
ensureStateDirs(paths);
|
|
152
|
+
const dir = path.dirname(paths.stateFile);
|
|
153
|
+
tmpSeq += 1;
|
|
154
|
+
const tmp = path.join(dir, `.${path.basename(paths.stateFile)}.tmp-${process.pid}-${tmpSeq}`);
|
|
155
|
+
const data = `${JSON.stringify(state, null, 2)}\n`;
|
|
156
|
+
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
|
157
|
+
fs.chmodSync(tmp, 0o600);
|
|
158
|
+
const fd = fs.openSync(tmp, 'r');
|
|
159
|
+
try {
|
|
160
|
+
fs.fsyncSync(fd);
|
|
161
|
+
} finally {
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
fs.renameSync(tmp, paths.stateFile);
|
|
165
|
+
fs.chmodSync(paths.stateFile, 0o600);
|
|
166
|
+
return paths.stateFile;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read-modify-write under a lock; the callback may be async and may mutate in place. */
|
|
170
|
+
export async function mutateState(target, fn) {
|
|
171
|
+
if (typeof fn !== 'function') throw pactiveError('MUTATOR_REQUIRED', 'mutateState needs a function');
|
|
172
|
+
const paths = toPaths(target);
|
|
173
|
+
ensureStateDirs(paths);
|
|
174
|
+
return withLock(paths.locksDir, 'state', async () => {
|
|
175
|
+
const current = loadState(paths);
|
|
176
|
+
const next = await fn(current);
|
|
177
|
+
const final = next ?? current;
|
|
178
|
+
saveState(paths, final);
|
|
179
|
+
return final;
|
|
180
|
+
}, {
|
|
181
|
+
onStale: (lockFile) => {
|
|
182
|
+
try {
|
|
183
|
+
ledger.append(paths.ledgerFile, { event: 'lock.stale_takeover', lock: path.basename(lockFile) });
|
|
184
|
+
} catch {
|
|
185
|
+
// best effort
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// DSH host adapter (contract §2). argv is verbatim per design §1: pactive owns a dedicated profile.
|
|
2
|
+
import { readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
conservativePresence,
|
|
8
|
+
defaultStateRoot,
|
|
9
|
+
makeResolver,
|
|
10
|
+
unreachableReceipt,
|
|
11
|
+
} from '../shared/index.mjs';
|
|
12
|
+
|
|
13
|
+
export const host = 'dsh';
|
|
14
|
+
export const SUPPORTED_VERSION = '0.2.1-rc.5';
|
|
15
|
+
export const MODE = 'profile';
|
|
16
|
+
|
|
17
|
+
export const resolveExec = makeResolver('dsh', 'dsh');
|
|
18
|
+
|
|
19
|
+
export function buildArgv({ pkgRoot, userArgs = [] }) {
|
|
20
|
+
return ['--profile', 'pactive', ...userArgs];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function plan({ pkgRoot, env = process.env, userArgs = [] }) {
|
|
24
|
+
let exec = null;
|
|
25
|
+
try {
|
|
26
|
+
exec = resolveExec({ env });
|
|
27
|
+
} catch {
|
|
28
|
+
exec = null; // host not installed — the plan stays printable (zero side effects)
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
host,
|
|
32
|
+
version: SUPPORTED_VERSION,
|
|
33
|
+
pluginRoot: pkgRoot,
|
|
34
|
+
exec,
|
|
35
|
+
mode: MODE,
|
|
36
|
+
argv: buildArgv({ pkgRoot, userArgs }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function capabilities() {
|
|
41
|
+
return { actions: ['prompt'], panel: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function presence(ctx = {}) {
|
|
45
|
+
return conservativePresence(host, ctx);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function inject(ctx, payload) {
|
|
49
|
+
return unreachableReceipt(`dsh adapter inject is not implemented yet (${payload?.kind ?? 'unknown'})`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function deliver(ctx, msg) {
|
|
53
|
+
return unreachableReceipt('dsh adapter deliver is not implemented yet');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function listSessions(ctx = {}) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function stateRoot(ctx = {}) {
|
|
61
|
+
return defaultStateRoot(ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function installArgs({ pkgRoot }) {
|
|
65
|
+
return ['plugin', '--profile', 'pactive', 'add', pkgRoot];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function uninstallArgs({ pkgRoot }) {
|
|
69
|
+
// pnpm removes by DEPENDENCY NAME, not by path: `pnpm remove /abs/path` exits 1 and leaves
|
|
70
|
+
// the profile untouched (defect found 2026-11-05 via install→uninstall→residue probe).
|
|
71
|
+
return ['plugin', '--profile', 'pactive', 'remove', packageName(pkgRoot)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The dependency name that `install` registered (falls back to the package's own default). */
|
|
75
|
+
function packageName(pkgRoot) {
|
|
76
|
+
try {
|
|
77
|
+
const pkg = JSON.parse(readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
|
|
78
|
+
if (typeof pkg.name === 'string' && pkg.name) return pkg.name;
|
|
79
|
+
} catch {
|
|
80
|
+
// manifest unreadable: fall back to the contract name
|
|
81
|
+
}
|
|
82
|
+
return 'pactive';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether the pactive profile currently exists (used to keep `uninstall` idempotent and quiet). */
|
|
86
|
+
export function isInstalled({ env }) {
|
|
87
|
+
const home = env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
88
|
+
const manifest = path.join(home, 'profiles', 'pactive', 'package.json');
|
|
89
|
+
try {
|
|
90
|
+
const pkg = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
91
|
+
return Boolean(pkg?.dependencies?.pactive) || (pkg?.dsh?.profile?.bundles || []).includes('pactive');
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Remove the pactive-owned DSH profile directory so `uninstall` leaves zero residue (I40). */
|
|
98
|
+
export function cleanup({ env }) {
|
|
99
|
+
const home = env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
100
|
+
const profileDir = path.join(home, 'profiles', 'pactive');
|
|
101
|
+
const manifest = path.join(profileDir, 'package.json');
|
|
102
|
+
let pkg;
|
|
103
|
+
try {
|
|
104
|
+
pkg = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
105
|
+
} catch {
|
|
106
|
+
return { removed: false, reason: 'no-profile' };
|
|
107
|
+
}
|
|
108
|
+
if (pkg.name !== 'dsh-profile-pactive') {
|
|
109
|
+
return { removed: false, reason: 'profile-not-owned' };
|
|
110
|
+
}
|
|
111
|
+
rmSync(profileDir, { recursive: true, force: true });
|
|
112
|
+
// 顺手删掉空的 profiles/:install 会创建它,留一个空目录仍会被算作"残留 1 项"(I40)。
|
|
113
|
+
const profilesDir = path.join(home, 'profiles');
|
|
114
|
+
try {
|
|
115
|
+
if (readdirSync(profilesDir).length === 0) rmSync(profilesDir, { recursive: true, force: true });
|
|
116
|
+
} catch {
|
|
117
|
+
// profiles/ 已不存在 —— 幂等
|
|
118
|
+
}
|
|
119
|
+
return { removed: true, path: profileDir };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function verifyArgs({ pkgRoot }) {
|
|
123
|
+
return ['plugin', '--profile', 'pactive', 'list'];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function verifyMatch({ pkgRoot }, output) {
|
|
127
|
+
return String(output ?? '').includes(pkgRoot);
|
|
128
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Contract §2 error taxonomy.
|
|
2
|
+
export class PactiveError extends Error {
|
|
3
|
+
constructor(message, { code = 'PACTIVE_ERROR', exitCode = 1, cause } = {}) {
|
|
4
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
5
|
+
this.name = 'PactiveError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The host binary could not be found → CLI exit code 127. */
|
|
12
|
+
export class HostMissingError extends PactiveError {
|
|
13
|
+
constructor(host, attempted = null) {
|
|
14
|
+
const envVar = `PACTIVE_${host.toUpperCase()}_BIN`;
|
|
15
|
+
super(
|
|
16
|
+
`host binary for '${host}' not found${attempted ? ` (${attempted})` : ''} — install '${host}' or set ${envVar}`,
|
|
17
|
+
{ code: 'HOST_MISSING', exitCode: 127 },
|
|
18
|
+
);
|
|
19
|
+
this.name = 'HostMissingError';
|
|
20
|
+
this.host = host;
|
|
21
|
+
this.attempted = attempted;
|
|
22
|
+
this.envVar = envVar;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// OPCteam host adapter (contract §2). argv is verbatim per design §1: the package root is the plugin root.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
conservativePresence,
|
|
6
|
+
defaultStateRoot,
|
|
7
|
+
makeResolver,
|
|
8
|
+
unreachableReceipt,
|
|
9
|
+
} from '../shared/index.mjs';
|
|
10
|
+
|
|
11
|
+
export const host = 'opcteam';
|
|
12
|
+
export const SUPPORTED_VERSION = '1.1.6-test';
|
|
13
|
+
export const MODE = 'plugin-dir';
|
|
14
|
+
|
|
15
|
+
export const resolveExec = makeResolver('opcteam', 'opcteam');
|
|
16
|
+
|
|
17
|
+
export function buildArgv({ pkgRoot, userArgs = [] }) {
|
|
18
|
+
return ['--plugin-dir', pkgRoot, ...userArgs];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function plan({ pkgRoot, env = process.env, userArgs = [] }) {
|
|
22
|
+
let exec = null;
|
|
23
|
+
try {
|
|
24
|
+
exec = resolveExec({ env });
|
|
25
|
+
} catch {
|
|
26
|
+
exec = null; // host not installed — the plan stays printable (zero side effects)
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
host,
|
|
30
|
+
version: SUPPORTED_VERSION,
|
|
31
|
+
pluginRoot: pkgRoot,
|
|
32
|
+
exec,
|
|
33
|
+
mode: MODE,
|
|
34
|
+
argv: buildArgv({ pkgRoot, userArgs }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function capabilities() {
|
|
39
|
+
return { actions: ['prompt'], panel: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function presence(ctx = {}) {
|
|
43
|
+
return conservativePresence(host, ctx);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function inject(ctx, payload) {
|
|
47
|
+
return unreachableReceipt(`opcteam adapter inject is not implemented yet (${payload?.kind ?? 'unknown'})`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function deliver(ctx, msg) {
|
|
51
|
+
return unreachableReceipt('opcteam adapter deliver is not implemented yet');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function listSessions(ctx = {}) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function stateRoot(ctx = {}) {
|
|
59
|
+
return defaultStateRoot(ctx);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function installArgs({ pkgRoot }) {
|
|
63
|
+
return ['plugin', 'marketplace', 'add', pkgRoot];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function uninstallArgs({ pkgRoot }) {
|
|
67
|
+
return ['plugin', 'marketplace', 'remove', path.basename(pkgRoot)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function verifyArgs({ pkgRoot }) {
|
|
71
|
+
return ['plugin', 'marketplace', 'list'];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function verifyMatch({ pkgRoot }, output) {
|
|
75
|
+
const text = String(output ?? '');
|
|
76
|
+
return text.includes(pkgRoot) || text.includes(path.basename(pkgRoot));
|
|
77
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Pi host adapter (contract §2). argv is verbatim per design §1/§7.1.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
conservativePresence,
|
|
6
|
+
defaultStateRoot,
|
|
7
|
+
makeResolver,
|
|
8
|
+
unreachableReceipt,
|
|
9
|
+
} from '../shared/index.mjs';
|
|
10
|
+
|
|
11
|
+
export const host = 'pi';
|
|
12
|
+
export const SUPPORTED_VERSION = '1.4.0';
|
|
13
|
+
export const MODE = 'extension+skill';
|
|
14
|
+
|
|
15
|
+
export const resolveExec = makeResolver('pi', 'pi');
|
|
16
|
+
|
|
17
|
+
export function buildArgv({ pkgRoot, userArgs = [] }) {
|
|
18
|
+
return [
|
|
19
|
+
'--extension',
|
|
20
|
+
path.join(pkgRoot, 'pi', 'extension.ts'),
|
|
21
|
+
'--skill',
|
|
22
|
+
path.join(pkgRoot, 'pi', 'skills', 'pactive'),
|
|
23
|
+
...userArgs,
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function plan({ pkgRoot, env = process.env, userArgs = [] }) {
|
|
28
|
+
let exec = null;
|
|
29
|
+
try {
|
|
30
|
+
exec = resolveExec({ env });
|
|
31
|
+
} catch {
|
|
32
|
+
exec = null; // host not installed — the plan stays printable (zero side effects)
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
host,
|
|
36
|
+
version: SUPPORTED_VERSION,
|
|
37
|
+
pluginRoot: pkgRoot,
|
|
38
|
+
exec,
|
|
39
|
+
mode: MODE,
|
|
40
|
+
argv: buildArgv({ pkgRoot, userArgs }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function capabilities() {
|
|
45
|
+
return { actions: ['prompt'], panel: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function presence(ctx = {}) {
|
|
49
|
+
return conservativePresence(host, ctx);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function inject(ctx, payload) {
|
|
53
|
+
return unreachableReceipt(`pi adapter inject is not implemented yet (${payload?.kind ?? 'unknown'})`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function deliver(ctx, msg) {
|
|
57
|
+
return unreachableReceipt('pi adapter deliver is not implemented yet');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function listSessions(ctx = {}) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function stateRoot(ctx = {}) {
|
|
65
|
+
return defaultStateRoot(ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function installArgs({ pkgRoot }) {
|
|
69
|
+
return ['install', pkgRoot];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function uninstallArgs({ pkgRoot }) {
|
|
73
|
+
return ['remove', pkgRoot];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function verifyArgs({ pkgRoot }) {
|
|
77
|
+
return ['list'];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function verifyMatch({ pkgRoot }, output) {
|
|
81
|
+
return String(output ?? '').includes(pkgRoot);
|
|
82
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Shared host-adapter helpers (contract §2).
|
|
2
|
+
// Host-specific knowledge stays in src/hosts/<host>/index.mjs; this file only holds
|
|
3
|
+
// the mechanics every adapter needs (binary resolution + conservative fallbacks).
|
|
4
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { HostMissingError } from '../errors.mjs';
|
|
9
|
+
|
|
10
|
+
export function isExecutable(p) {
|
|
11
|
+
try {
|
|
12
|
+
if (!statSync(p).isFile()) return false;
|
|
13
|
+
accessSync(p, constants.X_OK);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findOnPath(name, env = process.env) {
|
|
21
|
+
for (const dir of (env.PATH ?? '').split(path.delimiter)) {
|
|
22
|
+
if (!dir) continue;
|
|
23
|
+
const candidate = path.join(dir, name);
|
|
24
|
+
if (isExecutable(candidate)) return candidate;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** env.PACTIVE_<HOST>_BIN → PATH lookup → throw HostMissingError (exit 127). */
|
|
30
|
+
export function makeResolver(host, binaryName) {
|
|
31
|
+
return function resolveExec({ env = process.env } = {}) {
|
|
32
|
+
const override = env[`PACTIVE_${host.toUpperCase()}_BIN`];
|
|
33
|
+
if (override) {
|
|
34
|
+
if (isExecutable(override)) return path.resolve(override);
|
|
35
|
+
throw new HostMissingError(host, override);
|
|
36
|
+
}
|
|
37
|
+
const found = findOnPath(binaryName, env);
|
|
38
|
+
if (found) return found;
|
|
39
|
+
throw new HostMissingError(host, null);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Conservative presence: the CLI adapter cannot observe the host session itself. */
|
|
44
|
+
export function conservativePresence(host, ctx = {}) {
|
|
45
|
+
return {
|
|
46
|
+
online: false,
|
|
47
|
+
busy: false,
|
|
48
|
+
controlReturned: false,
|
|
49
|
+
lastUserActivityAt: null,
|
|
50
|
+
lastAgentActivityAt: null,
|
|
51
|
+
agentId: ctx.agentId ?? `unknown#${host}#unknown`,
|
|
52
|
+
sessionId: ctx.sessionId ?? 'unknown',
|
|
53
|
+
hostKind: host,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Never claim success for an unimplemented channel (contract §2 five-state receipt). */
|
|
58
|
+
export function unreachableReceipt(reason) {
|
|
59
|
+
return { ok: false, state: 'unreachable', reason, at: Date.now() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function defaultStateRoot(ctx = {}) {
|
|
63
|
+
return ctx.stateRoot ?? ctx.paths?.root ?? path.join(os.homedir(), '.pactive');
|
|
64
|
+
}
|