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
package/dsh/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pactive — DSH (Cordis) plugin.
|
|
3
|
+
*
|
|
4
|
+
* Registers the pactive tool surface (`pactive_status` / `pactive_schedule`) and the
|
|
5
|
+
* `/pactive` command, and subscribes to session events so the pactive activity
|
|
6
|
+
* timeline and the control flag stay accurate.
|
|
7
|
+
*
|
|
8
|
+
* The core module (`src/core/index.mjs`) is loaded lazily by relative path —
|
|
9
|
+
* the shared loader lives in `mcp/tools/_core.mjs` (same package) and degrades
|
|
10
|
+
* to a readable error when core is not built yet.
|
|
11
|
+
*
|
|
12
|
+
* unverified (DSH 0.2.1-rc.5 host API):
|
|
13
|
+
* - the session event names below are guesses; every subscription is wrapped
|
|
14
|
+
* in try/catch so an unknown event name cannot break plugin load;
|
|
15
|
+
* - `ctx.tools.register` / `ctx.commands.register` shapes follow the
|
|
16
|
+
* DSH Cordis plugin conventions documented in this repository's spec notes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { loadCore, mutateState, parseWhen, pickState, readState } from "../mcp/tools/_core.mjs";
|
|
20
|
+
|
|
21
|
+
export const name = "pactive";
|
|
22
|
+
|
|
23
|
+
/** Every Cordis service this plugin touches must be declared here. */
|
|
24
|
+
export const inject = ["tools", "commands", "sessions"];
|
|
25
|
+
|
|
26
|
+
const HOST_KIND = "dsh";
|
|
27
|
+
|
|
28
|
+
function errorText(err) {
|
|
29
|
+
return String(err?.message ?? err);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeLog(ctx, level, message, extra) {
|
|
33
|
+
try {
|
|
34
|
+
ctx?.log?.(level, message, extra);
|
|
35
|
+
} catch {
|
|
36
|
+
/* nothing left to report with */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Mark an activity kind; never throws. */
|
|
41
|
+
async function markActivity(kind, ctx) {
|
|
42
|
+
try {
|
|
43
|
+
const core = await loadCore();
|
|
44
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
45
|
+
await mutateState(core, paths, (state) => {
|
|
46
|
+
const next = core.markActivity(state, kind);
|
|
47
|
+
return next && typeof next === "object" ? next : state;
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
safeLog(ctx, "warn", `pactive: markActivity(${kind}) failed`, { error: errorText(err) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Flip the control flag (the agent stopped → control returned to the human). */
|
|
55
|
+
async function markControlReturned(ctx) {
|
|
56
|
+
try {
|
|
57
|
+
const core = await loadCore();
|
|
58
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
59
|
+
await mutateState(core, paths, (state) => {
|
|
60
|
+
const next = core.markControlReturned(state);
|
|
61
|
+
return next && typeof next === "object" ? next : state;
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
safeLog(ctx, "warn", "pactive: markControlReturned failed", { error: errorText(err) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const STATUS_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
|
|
69
|
+
const SCHEDULE_PARAMETERS = {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
prompt: { type: "string", description: "任务正文(到点注入给会话的内容)" },
|
|
73
|
+
when: { type: "string", description: "触发时间:时长(10m)/ 时刻(18:30)/ ISO 8601 / every 1h" },
|
|
74
|
+
},
|
|
75
|
+
required: ["prompt"],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {any} ctx Cordis plugin context
|
|
81
|
+
* @param {Record<string, any>} config
|
|
82
|
+
*/
|
|
83
|
+
export function apply(ctx, config) {
|
|
84
|
+
const pluginConfig = config ?? {};
|
|
85
|
+
|
|
86
|
+
const registerTools = () => {
|
|
87
|
+
if (!ctx?.tools?.register) return;
|
|
88
|
+
|
|
89
|
+
ctx.tools.register({
|
|
90
|
+
name: "pactive_status",
|
|
91
|
+
description:
|
|
92
|
+
"Pactive 主动机制状态:空闲判定与 reason、护栏四闸门、定时任务数、最近活动。只读。",
|
|
93
|
+
parameters: STATUS_PARAMETERS,
|
|
94
|
+
async execute(args = {}) {
|
|
95
|
+
try {
|
|
96
|
+
const core = await loadCore();
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
99
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
100
|
+
const idle = core.evaluateIdle
|
|
101
|
+
? core.evaluateIdle(state, now, state.policy ?? {})
|
|
102
|
+
: { idle: false, reason: "requires-action" };
|
|
103
|
+
const guard = core.checkGuard ? core.checkGuard(state, now) : { pass: false, reason: "requires-action" };
|
|
104
|
+
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
105
|
+
const activity = core.getActivity ? core.getActivity(state) : null;
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
host: HOST_KIND,
|
|
109
|
+
now,
|
|
110
|
+
enabled: state.idle?.enabled ?? false,
|
|
111
|
+
idle,
|
|
112
|
+
guard,
|
|
113
|
+
tasks: Array.isArray(tasks) ? tasks.length : 0,
|
|
114
|
+
activity,
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { ok: false, error: errorText(err) };
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
ctx.tools.register({
|
|
123
|
+
name: "pactive_schedule",
|
|
124
|
+
description: "登记一个定时任务(真正落盘)。when 支持时长(10m)、时刻(18:30)、ISO 时间、every 1h。",
|
|
125
|
+
parameters: SCHEDULE_PARAMETERS,
|
|
126
|
+
async execute(args = {}) {
|
|
127
|
+
try {
|
|
128
|
+
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
|
129
|
+
if (prompt === "") return { ok: false, error: "prompt 必填(任务正文不能为空)" };
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const spec = parseWhen(args.when, now);
|
|
132
|
+
if (!spec) return { ok: false, error: `无法解析 when:${String(args.when)}` };
|
|
133
|
+
const core = await loadCore();
|
|
134
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
135
|
+
const next = await mutateState(core, paths, (state) => {
|
|
136
|
+
const out = core.schedule.add(
|
|
137
|
+
state,
|
|
138
|
+
{ ...spec, prompt, content: prompt, owner: state.identity?.agentId },
|
|
139
|
+
now,
|
|
140
|
+
);
|
|
141
|
+
return pickState(out, state);
|
|
142
|
+
});
|
|
143
|
+
const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
|
|
144
|
+
const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
taskId: created?.id ?? created?.taskId ?? null,
|
|
148
|
+
dueAt: created?.dueAt ?? spec.at ?? null,
|
|
149
|
+
kind: spec.kind,
|
|
150
|
+
when: spec.when,
|
|
151
|
+
tasks: Array.isArray(tasks) ? tasks.length : 0,
|
|
152
|
+
};
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { ok: false, error: errorText(err) };
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const registerCommands = () => {
|
|
161
|
+
if (!ctx?.commands?.register) return;
|
|
162
|
+
|
|
163
|
+
ctx.commands.register({
|
|
164
|
+
name: "pactive",
|
|
165
|
+
description: "Pactive:status | tasks | schedule <when> <prompt> | help",
|
|
166
|
+
input: { hint: "status | tasks | schedule <when> <prompt> | help" },
|
|
167
|
+
async handler(invocation) {
|
|
168
|
+
const raw = String(invocation?.rawInput ?? "").trim();
|
|
169
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
170
|
+
const sub = parts[0] ?? "status";
|
|
171
|
+
const fail = (text) => ({ kind: "error", text });
|
|
172
|
+
const ok = (text) => ({ kind: "success", text });
|
|
173
|
+
try {
|
|
174
|
+
const core = await loadCore();
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
177
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
178
|
+
if (sub === "status") {
|
|
179
|
+
const idle = core.evaluateIdle
|
|
180
|
+
? core.evaluateIdle(state, now, state.policy ?? {})
|
|
181
|
+
: { idle: false, reason: "requires-action" };
|
|
182
|
+
const guard = core.checkGuard ? core.checkGuard(state, now) : { pass: false, reason: "requires-action" };
|
|
183
|
+
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
184
|
+
return ok(
|
|
185
|
+
`pactive: idle=${idle.idle} reason=${idle.reason} guard=${guard.pass ? "pass" : "blocked"} ` +
|
|
186
|
+
`(${guard.reason ?? "-"}) tasks=${Array.isArray(tasks) ? tasks.length : 0}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (sub === "tasks") {
|
|
190
|
+
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
191
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
192
|
+
return ok(
|
|
193
|
+
list.length === 0
|
|
194
|
+
? "pactive: 没有定时任务"
|
|
195
|
+
: `pactive: ${list.length} 个定时任务\n` +
|
|
196
|
+
list.map((task) => `- [${task?.id ?? "?"}] ${task?.prompt ?? task?.content ?? ""}`).join("\n"),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (sub === "schedule") {
|
|
200
|
+
const when = parts[1] ?? "10m";
|
|
201
|
+
const prompt = parts.slice(2).join(" ");
|
|
202
|
+
if (prompt === "") return fail("用法:/pactive schedule <when> <prompt>");
|
|
203
|
+
const spec = parseWhen(when, now);
|
|
204
|
+
if (!spec) return fail(`pactive: 无法解析 when(${when})`);
|
|
205
|
+
const next = await mutateState(core, paths, (current) => {
|
|
206
|
+
const out = core.schedule.add(
|
|
207
|
+
current,
|
|
208
|
+
{ ...spec, prompt, content: prompt, owner: current.identity?.agentId },
|
|
209
|
+
now,
|
|
210
|
+
);
|
|
211
|
+
return pickState(out, current);
|
|
212
|
+
});
|
|
213
|
+
const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
|
|
214
|
+
const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
|
|
215
|
+
return ok(`pactive: 已登记定时任务 ${created?.id ?? "-"}(${when})`);
|
|
216
|
+
}
|
|
217
|
+
return fail("用法:/pactive status | tasks | schedule <when> <prompt> | help");
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return fail(`pactive 失败:${errorText(err)}`);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Session events → activity tracking.
|
|
227
|
+
* unverified: the DSH event names are not documented in this repository, so
|
|
228
|
+
* every candidate is subscribed defensively.
|
|
229
|
+
*/
|
|
230
|
+
const subscribeSessionEvents = () => {
|
|
231
|
+
const sessions = ctx?.sessions;
|
|
232
|
+
if (!sessions || typeof sessions.on !== "function") return;
|
|
233
|
+
const activityCandidates = [
|
|
234
|
+
["session.start", "session-start"],
|
|
235
|
+
["sessionStart", "session-start"],
|
|
236
|
+
["session.end", "session-end"],
|
|
237
|
+
["sessionEnd", "session-end"],
|
|
238
|
+
["message", "user"],
|
|
239
|
+
["user_message", "user"],
|
|
240
|
+
["agent.end", "agent"],
|
|
241
|
+
["agent_end", "agent"],
|
|
242
|
+
["message_end", "agent"],
|
|
243
|
+
];
|
|
244
|
+
for (const [event, kind] of activityCandidates) {
|
|
245
|
+
try {
|
|
246
|
+
sessions.on(event, () => {
|
|
247
|
+
void markActivity(kind, ctx);
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
safeLog(ctx, "debug", `pactive: session event ${event} unavailable`, { error: errorText(err) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// The agent settled → control returned to the human.
|
|
254
|
+
for (const event of ["agent.settled", "agent_settled", "agent.idle"]) {
|
|
255
|
+
try {
|
|
256
|
+
sessions.on(event, () => {
|
|
257
|
+
void markControlReturned(ctx);
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
safeLog(ctx, "debug", `pactive: session event ${event} unavailable`, { error: errorText(err) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
registerTools();
|
|
267
|
+
registerCommands();
|
|
268
|
+
subscribeSessionEvents();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
safeLog(ctx, "error", "pactive: failed to register tools/commands", { error: errorText(err) });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The agent stops → control returns to the human.
|
|
274
|
+
try {
|
|
275
|
+
ctx?.effect?.(() => () => {
|
|
276
|
+
void markControlReturned(ctx);
|
|
277
|
+
});
|
|
278
|
+
} catch (err) {
|
|
279
|
+
safeLog(ctx, "warn", "pactive: ctx.effect unavailable; control flag updated on events only", {
|
|
280
|
+
error: errorText(err),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
safeLog(ctx, "info", "pactive plugin loaded", { config: pluginConfig });
|
|
285
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node \"${OPCTEAM_PLUGIN_ROOT}/hooks/session-start.mjs\""
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"UserPromptSubmit": [
|
|
14
|
+
{
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "node \"${OPCTEAM_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs\""
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"PostToolUse": [
|
|
24
|
+
{
|
|
25
|
+
"hooks": [
|
|
26
|
+
{
|
|
27
|
+
"type": "command",
|
|
28
|
+
"command": "node \"${OPCTEAM_PLUGIN_ROOT}/hooks/post-tool-use.mjs\""
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"Stop": [
|
|
34
|
+
{
|
|
35
|
+
"hooks": [
|
|
36
|
+
{
|
|
37
|
+
"type": "command",
|
|
38
|
+
"command": "node \"${OPCTEAM_PLUGIN_ROOT}/hooks/stop.mjs\""
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"SessionEnd": [
|
|
44
|
+
{
|
|
45
|
+
"hooks": [
|
|
46
|
+
{
|
|
47
|
+
"type": "command",
|
|
48
|
+
"command": "node \"${OPCTEAM_PLUGIN_ROOT}/hooks/session-end.mjs\""
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse hook — record tool activity (an agent-side action).
|
|
4
|
+
*
|
|
5
|
+
* Tool activity refreshes the "something is happening" baseline but must not
|
|
6
|
+
* refresh the *human* activity baseline.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { markActivity, readStdinJson, writeHookOutput } from "../mcp/tools/_core.mjs";
|
|
10
|
+
|
|
11
|
+
await readStdinJson();
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await markActivity("tool");
|
|
15
|
+
} catch (err) {
|
|
16
|
+
process.stderr.write(`[pactive] PostToolUse: ${String(err?.message ?? err)}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
writeHookOutput({});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionEnd hook — record that the session ended.
|
|
4
|
+
*
|
|
5
|
+
* The session id / cwd come from the hook payload; they are recorded by the
|
|
6
|
+
* core's activity timeline so `doctor` can report stale sessions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { markActivity, readStdinJson, writeHookOutput } from "../mcp/tools/_core.mjs";
|
|
10
|
+
|
|
11
|
+
await readStdinJson();
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await markActivity("session-end");
|
|
15
|
+
} catch (err) {
|
|
16
|
+
process.stderr.write(`[pactive] SessionEnd: ${String(err?.message ?? err)}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
writeHookOutput({});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart hook — record that a session started.
|
|
4
|
+
*
|
|
5
|
+
* Contract: hooks/hooks.json → `SessionStart` → `node "${OPCTEAM_PLUGIN_ROOT}/hooks/session-start.mjs"`.
|
|
6
|
+
* Protocol: read the hook payload from stdin, write the hook decision to stdout.
|
|
7
|
+
* A hook must never break the host session: every failure degrades to `{}`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { markActivity, readStdinJson, writeHookOutput } from "../mcp/tools/_core.mjs";
|
|
11
|
+
|
|
12
|
+
await readStdinJson();
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await markActivity("session-start");
|
|
16
|
+
} catch (err) {
|
|
17
|
+
process.stderr.write(`[pactive] SessionStart: ${String(err?.message ?? err)}\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
writeHookOutput({});
|
package/hooks/stop.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stop hook — control returned to the human; deliver due work at most once.
|
|
4
|
+
*
|
|
5
|
+
* Loop protection (I27): the hook may block **at most once per turn**.
|
|
6
|
+
* - `stop_hook_active === true` → the host is already re-running because of a
|
|
7
|
+
* previous block → never block again.
|
|
8
|
+
* - `stopBlockCount` (persisted in the state) → blocks once per turn; the
|
|
9
|
+
* `UserPromptSubmit` hook resets it at the turn boundary.
|
|
10
|
+
*
|
|
11
|
+
* Output: `{}` (pass through) or `{"decision":"block","reason":"…"}`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
loadCore,
|
|
16
|
+
mutateState,
|
|
17
|
+
readStdinJson,
|
|
18
|
+
writeHookOutput,
|
|
19
|
+
} from "../mcp/tools/_core.mjs";
|
|
20
|
+
|
|
21
|
+
const input = await readStdinJson();
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const core = await loadCore();
|
|
26
|
+
const paths = core.resolvePaths("opcteam", process.env);
|
|
27
|
+
|
|
28
|
+
// 1) control returned to the human (the agent stopped).
|
|
29
|
+
const state = await mutateState(core, paths, (current) => {
|
|
30
|
+
const next = core.markControlReturned(current, now);
|
|
31
|
+
return next && typeof next === "object" ? next : current;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// 2) never block inside a block.
|
|
35
|
+
if (input?.stop_hook_active === true) {
|
|
36
|
+
writeHookOutput({});
|
|
37
|
+
} else {
|
|
38
|
+
// 3) at most one block per turn.
|
|
39
|
+
const blocked = Number(state?.stopBlockCount ?? 0) >= 1;
|
|
40
|
+
const due = blocked ? [] : core.schedule?.due ? core.schedule.due(state, now) : [];
|
|
41
|
+
const tasks = Array.isArray(due) ? due : [];
|
|
42
|
+
|
|
43
|
+
if (tasks.length === 0) {
|
|
44
|
+
writeHookOutput({});
|
|
45
|
+
} else {
|
|
46
|
+
const lines = tasks.map((task) => `- [${task?.id ?? "?"}] ${task?.prompt ?? task?.content ?? ""}`.trim());
|
|
47
|
+
const reason = `pactive: ${tasks.length} 个定时任务到期\n${lines.join("\n")}`;
|
|
48
|
+
await mutateState(core, paths, (current) => ({ ...current, stopBlockCount: 1, controlReturned: false }));
|
|
49
|
+
writeHookOutput({ decision: "block", reason });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
process.stderr.write(`[pactive] Stop: ${String(err?.message ?? err)}\n`);
|
|
54
|
+
writeHookOutput({});
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* UserPromptSubmit hook — record human activity and re-arm the Stop guard.
|
|
4
|
+
*
|
|
5
|
+
* The Stop hook may block **at most once per turn** (I27). This hook is the
|
|
6
|
+
* turn boundary: it resets the per-turn block counter so the next turn may
|
|
7
|
+
* block once again.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
loadCore,
|
|
12
|
+
markActivity,
|
|
13
|
+
mutateState,
|
|
14
|
+
readStdinJson,
|
|
15
|
+
writeHookOutput,
|
|
16
|
+
} from "../mcp/tools/_core.mjs";
|
|
17
|
+
|
|
18
|
+
await readStdinJson();
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await markActivity("user");
|
|
22
|
+
} catch (err) {
|
|
23
|
+
process.stderr.write(`[pactive] UserPromptSubmit: ${String(err?.message ?? err)}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const core = await loadCore();
|
|
28
|
+
const paths = core.resolvePaths("opcteam", process.env);
|
|
29
|
+
await mutateState(core, paths, (state) => ({ ...state, stopBlockCount: 0 }));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
process.stderr.write(`[pactive] UserPromptSubmit (reset): ${String(err?.message ?? err)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
writeHookOutput({});
|
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `pactive` MCP server for OPCteam — stdio JSON-RPC, zero third-party dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Exposes: pactive_status / pactive_schedule / pactive_tell / pactive_history.
|
|
6
|
+
* Protocol: newline-delimited JSON-RPC 2.0 (MCP `initialize` / `tools/list` /
|
|
7
|
+
* `tools/call`), the same wire shape the OPCteam plugin loader expects.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { TOOL_DEFINITIONS, findTool, runTool } from "./tools/definitions.mjs";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PROTOCOL = "2024-11-05";
|
|
17
|
+
const SERVER_NAME = "pactive";
|
|
18
|
+
const SERVER_VERSION = "0.0.2-alpha.1";
|
|
19
|
+
|
|
20
|
+
/** @param {NodeJS.WritableStream} output */
|
|
21
|
+
function send(output, message) {
|
|
22
|
+
try {
|
|
23
|
+
output.write(JSON.stringify(message) + "\n");
|
|
24
|
+
} catch {
|
|
25
|
+
/* host closed the pipe */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Handle one JSON-RPC message.
|
|
31
|
+
* @param {any} message
|
|
32
|
+
* @param {{output: NodeJS.WritableStream, env: Record<string,string|undefined>}} ctx
|
|
33
|
+
*/
|
|
34
|
+
export async function handleMessage(message, { output, env }) {
|
|
35
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
36
|
+
send(output, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { id, method, params } = message;
|
|
40
|
+
if (id === undefined || id === null) return; // notification
|
|
41
|
+
|
|
42
|
+
switch (method) {
|
|
43
|
+
case "initialize":
|
|
44
|
+
send(output, {
|
|
45
|
+
jsonrpc: "2.0",
|
|
46
|
+
id,
|
|
47
|
+
result: {
|
|
48
|
+
protocolVersion: params?.protocolVersion || DEFAULT_PROTOCOL,
|
|
49
|
+
capabilities: { tools: {} },
|
|
50
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
case "ping":
|
|
55
|
+
send(output, { jsonrpc: "2.0", id, result: {} });
|
|
56
|
+
return;
|
|
57
|
+
case "tools/list":
|
|
58
|
+
send(output, { jsonrpc: "2.0", id, result: { tools: TOOL_DEFINITIONS } });
|
|
59
|
+
return;
|
|
60
|
+
case "tools/call": {
|
|
61
|
+
const name = params?.name;
|
|
62
|
+
if (!findTool(name)) {
|
|
63
|
+
send(output, {
|
|
64
|
+
jsonrpc: "2.0",
|
|
65
|
+
id,
|
|
66
|
+
result: { content: [{ type: "text", text: `ERROR UNKNOWN_TOOL: ${name}` }], isError: true },
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const args = params?.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
|
71
|
+
try {
|
|
72
|
+
const result = await runTool(name, args, { env });
|
|
73
|
+
send(output, { jsonrpc: "2.0", id, result });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
send(output, {
|
|
76
|
+
jsonrpc: "2.0",
|
|
77
|
+
id,
|
|
78
|
+
result: {
|
|
79
|
+
content: [{ type: "text", text: `ERROR INTERNAL: ${String(err?.message ?? err)}` }],
|
|
80
|
+
isError: true,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
send(output, {
|
|
88
|
+
jsonrpc: "2.0",
|
|
89
|
+
id,
|
|
90
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Start the stdio server.
|
|
97
|
+
* @param {{input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, env?: Record<string,string|undefined>}} [options]
|
|
98
|
+
*/
|
|
99
|
+
export function startServer(options = {}) {
|
|
100
|
+
const input = options.input ?? process.stdin;
|
|
101
|
+
const output = options.output ?? process.stdout;
|
|
102
|
+
const env = options.env ?? process.env;
|
|
103
|
+
let buffer = "";
|
|
104
|
+
|
|
105
|
+
const onLine = (line) => {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
if (!trimmed) return;
|
|
108
|
+
let message;
|
|
109
|
+
try {
|
|
110
|
+
message = JSON.parse(trimmed);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
send(output, {
|
|
113
|
+
jsonrpc: "2.0",
|
|
114
|
+
id: null,
|
|
115
|
+
error: { code: -32700, message: `Parse error: ${String(err?.message ?? err)}` },
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
Promise.resolve(handleMessage(message, { output, env })).catch((err) => {
|
|
120
|
+
send(output, {
|
|
121
|
+
jsonrpc: "2.0",
|
|
122
|
+
id: null,
|
|
123
|
+
error: { code: -32603, message: String(err?.message ?? err) },
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
input.setEncoding("utf8");
|
|
129
|
+
input.on("data", (chunk) => {
|
|
130
|
+
buffer += chunk;
|
|
131
|
+
let index;
|
|
132
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
133
|
+
const line = buffer.slice(0, index);
|
|
134
|
+
buffer = buffer.slice(index + 1);
|
|
135
|
+
onLine(line);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
input.on("end", () => {
|
|
139
|
+
if (buffer.trim()) onLine(buffer);
|
|
140
|
+
buffer = "";
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDirectRun() {
|
|
145
|
+
const entry = process.argv[1];
|
|
146
|
+
if (!entry) return false;
|
|
147
|
+
const resolved = path.resolve(entry);
|
|
148
|
+
try {
|
|
149
|
+
return import.meta.url === new URL(`file://${resolved}`).href || fs.realpathSync(resolved) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isDirectRun()) startServer();
|