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/pi/extension.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pactive — Pi extension (the single shipped entry (Pi loads this .ts directly via jiti; there is no dist copy)).
|
|
3
|
+
*
|
|
4
|
+
* Surface:
|
|
5
|
+
* - tools: `pactive_status`, `pactive_schedule`
|
|
6
|
+
* - command: `/pactive status|tasks|schedule <when> <prompt>|help`
|
|
7
|
+
* - events: session_start / input / agent_start / tool_execution_start /
|
|
8
|
+
* message_end / agent_settled / session_shutdown → activity + control tracking
|
|
9
|
+
*
|
|
10
|
+
* The extension never throws into the host: every core interaction is wrapped
|
|
11
|
+
* in try/catch and degrades to a readable message.
|
|
12
|
+
*
|
|
13
|
+
* unverified (Pi 1.4.0 host API):
|
|
14
|
+
* - the event names below come from the Pi extension docs; the payload shapes
|
|
15
|
+
* are not used except for `ctx.sessionManager.getSessionId()`.
|
|
16
|
+
* - `parameters` (JSON Schema) is used as the tool schema field; the detailed
|
|
17
|
+
* design wrote `schema` — the shipped Pi examples use `parameters`.
|
|
18
|
+
* - `src/core/index.mjs` is loaded by relative path at runtime; the argument
|
|
19
|
+
* shapes of loadState/saveState/mutateState are not frozen yet, so several
|
|
20
|
+
* plausible shapes are tried before falling back to an explicit write.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Minimal mirror of the host API (kept local so the extension stays dependency-free). */
|
|
24
|
+
export interface PactiveToolResult {
|
|
25
|
+
content: Array<{ type: "text"; text: string }>;
|
|
26
|
+
details?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PactiveToolDefinition {
|
|
30
|
+
name: string;
|
|
31
|
+
label?: string;
|
|
32
|
+
description: string;
|
|
33
|
+
parameters: Record<string, unknown>;
|
|
34
|
+
execute(
|
|
35
|
+
toolCallId: string,
|
|
36
|
+
params: Record<string, any>,
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
onUpdate?: (update: unknown) => void,
|
|
39
|
+
ctx?: unknown,
|
|
40
|
+
): Promise<PactiveToolResult>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PactiveExtensionAPI {
|
|
44
|
+
registerTool(tool: PactiveToolDefinition): void;
|
|
45
|
+
registerCommand(
|
|
46
|
+
name: string,
|
|
47
|
+
definition: {
|
|
48
|
+
description: string;
|
|
49
|
+
handler(args: string, ctx: PactiveUiContext): Promise<void> | void;
|
|
50
|
+
},
|
|
51
|
+
): void;
|
|
52
|
+
on(event: string, handler: (event: any, ctx?: any) => void): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PactiveUiContext {
|
|
56
|
+
ui?: { notify?: (message: string, level?: string) => void };
|
|
57
|
+
sessionManager?: { getSessionId?: () => string | undefined };
|
|
58
|
+
cwd?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const HOST_KIND = "pi";
|
|
62
|
+
|
|
63
|
+
/** Relative candidates for the core module (resolved against this file). */
|
|
64
|
+
const CORE_CANDIDATES = ["../src/core/index.mjs", "../../src/core/index.mjs"];
|
|
65
|
+
|
|
66
|
+
let cachedCore: unknown = null;
|
|
67
|
+
|
|
68
|
+
function errorText(err: unknown): string {
|
|
69
|
+
return String((err as { message?: unknown })?.message ?? err);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Load `src/core/index.mjs` lazily; throws a readable error when it is missing. */
|
|
73
|
+
async function loadCore(): Promise<any> {
|
|
74
|
+
if (cachedCore) return cachedCore;
|
|
75
|
+
const env = process.env;
|
|
76
|
+
const explicit = env.PACTIVE_CORE_MODULE;
|
|
77
|
+
const candidates = explicit ? [explicit, ...CORE_CANDIDATES] : CORE_CANDIDATES;
|
|
78
|
+
const tried: string[] = [];
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const href = candidate.startsWith("/")
|
|
81
|
+
? `file://${candidate}`
|
|
82
|
+
: new URL(candidate, import.meta.url).href;
|
|
83
|
+
tried.push(href);
|
|
84
|
+
try {
|
|
85
|
+
const mod: any = await import(href);
|
|
86
|
+
if (mod && typeof mod.resolvePaths === "function") {
|
|
87
|
+
cachedCore = mod;
|
|
88
|
+
return mod;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* try the next candidate */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw new Error(
|
|
95
|
+
`pactive: core module not found (tried: ${tried.join(", ")}). Build src/core before loading the extension.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readState(core: any, paths: any): Promise<any> {
|
|
100
|
+
for (const args of [[paths.stateFile], [paths]]) {
|
|
101
|
+
try {
|
|
102
|
+
const state = await core.loadState(...args);
|
|
103
|
+
if (state && typeof state === "object") return state;
|
|
104
|
+
} catch {
|
|
105
|
+
/* next shape */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Mutate the state through core (locking + atomic write), verifying that the
|
|
113
|
+
* write landed in this host's state file. `fn` must be pure.
|
|
114
|
+
*/
|
|
115
|
+
async function mutateState(core: any, paths: any, fn: (state: any) => any): Promise<any> {
|
|
116
|
+
const before = (await readState(core, paths)) ?? core.defaultState();
|
|
117
|
+
const shapes: unknown[][] = [
|
|
118
|
+
[fn, paths],
|
|
119
|
+
[paths, fn],
|
|
120
|
+
[fn],
|
|
121
|
+
];
|
|
122
|
+
for (const args of shapes) {
|
|
123
|
+
try {
|
|
124
|
+
const out = await core.mutateState(...(args as [any]));
|
|
125
|
+
const after = await readState(core, paths);
|
|
126
|
+
if (after) {
|
|
127
|
+
const wanted = fn(before);
|
|
128
|
+
if (JSON.stringify(after) !== JSON.stringify(wanted)) {
|
|
129
|
+
await writeState(core, paths, wanted);
|
|
130
|
+
return wanted;
|
|
131
|
+
}
|
|
132
|
+
return after;
|
|
133
|
+
}
|
|
134
|
+
return out ?? fn(before);
|
|
135
|
+
} catch {
|
|
136
|
+
/* next shape */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const after = fn(before);
|
|
140
|
+
await writeState(core, paths, after);
|
|
141
|
+
return after;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeState(core: any, paths: any, state: any): Promise<void> {
|
|
145
|
+
const shapes: unknown[][] = [
|
|
146
|
+
[paths.stateFile, state],
|
|
147
|
+
[state, paths.stateFile],
|
|
148
|
+
[state],
|
|
149
|
+
];
|
|
150
|
+
let last: unknown;
|
|
151
|
+
for (const args of shapes) {
|
|
152
|
+
try {
|
|
153
|
+
await core.saveState(...(args as [any]));
|
|
154
|
+
return;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
last = err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse `10m` / `18:30` / ISO 8601 / `every 1h` into a schedule spec. */
|
|
163
|
+
function parseWhen(when: unknown, now = Date.now()): { kind: string; when: string; at: number | null } | null {
|
|
164
|
+
const text = typeof when === "string" ? when.trim() : "";
|
|
165
|
+
if (text === "") return { kind: "in", when: "10m", at: null };
|
|
166
|
+
const every = /^every\s+(.+)$/i.exec(text);
|
|
167
|
+
if (every) return { kind: "every", when: every[1].trim(), at: null };
|
|
168
|
+
const duration = /^(\d+)\s*(s|sec|m|min|h|hour|d|day)$/i.exec(text);
|
|
169
|
+
if (duration) {
|
|
170
|
+
const unit = duration[2].toLowerCase();
|
|
171
|
+
const factor = unit.startsWith("s")
|
|
172
|
+
? 1000
|
|
173
|
+
: unit.startsWith("m")
|
|
174
|
+
? 60000
|
|
175
|
+
: unit.startsWith("h")
|
|
176
|
+
? 3600000
|
|
177
|
+
: 86400000;
|
|
178
|
+
return { kind: "in", when: text, at: now + Number(duration[1]) * factor };
|
|
179
|
+
}
|
|
180
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(text);
|
|
181
|
+
if (clock) {
|
|
182
|
+
const at = new Date(now);
|
|
183
|
+
at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
184
|
+
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
|
|
185
|
+
return { kind: "at", when: text, at: at.getTime() };
|
|
186
|
+
}
|
|
187
|
+
const iso = Date.parse(text);
|
|
188
|
+
if (!Number.isNaN(iso)) return { kind: "at", when: text, at: iso };
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function textResult(text: string, details?: Record<string, unknown>): PactiveToolResult {
|
|
193
|
+
return details ? { content: [{ type: "text", text }], details } : { content: [{ type: "text", text }] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const STATUS_PARAMETERS = { type: "object", properties: {}, additionalProperties: false };
|
|
197
|
+
const SCHEDULE_PARAMETERS = {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
prompt: { type: "string", description: "任务正文(到点注入给会话的内容)" },
|
|
201
|
+
when: { type: "string", description: "触发时间:时长(10m)/ 时刻(18:30)/ ISO 8601 / every 1h" },
|
|
202
|
+
},
|
|
203
|
+
required: ["prompt"],
|
|
204
|
+
additionalProperties: false,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Mark an activity kind; never throws. */
|
|
208
|
+
async function markActivity(kind: "user" | "agent" | "session-start" | "session-end" | "tool"): Promise<void> {
|
|
209
|
+
try {
|
|
210
|
+
const core = await loadCore();
|
|
211
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
212
|
+
await mutateState(core, paths, (state) => {
|
|
213
|
+
const next = core.markActivity(state, kind);
|
|
214
|
+
return next && typeof next === "object" ? next : state;
|
|
215
|
+
});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
process.stderr.write(`[pactive] markActivity(${kind}): ${errorText(err)}\n`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Flip the control flag (the agent stopped → control returned to the human). */
|
|
222
|
+
async function markControlReturned(): Promise<void> {
|
|
223
|
+
try {
|
|
224
|
+
const core = await loadCore();
|
|
225
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
226
|
+
await mutateState(core, paths, (state) => {
|
|
227
|
+
const next = core.markControlReturned(state);
|
|
228
|
+
return next && typeof next === "object" ? next : state;
|
|
229
|
+
});
|
|
230
|
+
} catch (err) {
|
|
231
|
+
process.stderr.write(`[pactive] markControlReturned: ${errorText(err)}\n`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default function activate(pi: PactiveExtensionAPI): void {
|
|
236
|
+
pi.registerTool({
|
|
237
|
+
name: "pactive_status",
|
|
238
|
+
label: "Pactive Status",
|
|
239
|
+
description:
|
|
240
|
+
"Pactive 主动机制状态:空闲判定与 reason、护栏四闸门、定时任务数、最近活动。只读。",
|
|
241
|
+
parameters: STATUS_PARAMETERS,
|
|
242
|
+
async execute() {
|
|
243
|
+
try {
|
|
244
|
+
const core = await loadCore();
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
247
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
248
|
+
const idle = core.evaluateIdle
|
|
249
|
+
? core.evaluateIdle(state, now, state.policy ?? {})
|
|
250
|
+
: { idle: false, reason: "requires-action" };
|
|
251
|
+
const guard = core.checkGuard ? core.checkGuard(state, now) : { pass: false, reason: "requires-action" };
|
|
252
|
+
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
253
|
+
const activity = core.getActivity ? core.getActivity(state) : null;
|
|
254
|
+
return textResult(
|
|
255
|
+
JSON.stringify(
|
|
256
|
+
{
|
|
257
|
+
host: HOST_KIND,
|
|
258
|
+
now,
|
|
259
|
+
enabled: state.idle?.enabled ?? false,
|
|
260
|
+
idle,
|
|
261
|
+
guard,
|
|
262
|
+
tasks: Array.isArray(tasks) ? tasks.length : 0,
|
|
263
|
+
activity,
|
|
264
|
+
},
|
|
265
|
+
null,
|
|
266
|
+
2,
|
|
267
|
+
),
|
|
268
|
+
{ host: HOST_KIND },
|
|
269
|
+
);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
return textResult(`pactive_status 失败:${errorText(err)}`, { error: true });
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
pi.registerTool({
|
|
277
|
+
name: "pactive_schedule",
|
|
278
|
+
label: "Pactive Schedule",
|
|
279
|
+
description: "登记一个定时任务(真正落盘)。when 支持时长(10m)、时刻(18:30)、ISO 时间、every 1h。",
|
|
280
|
+
parameters: SCHEDULE_PARAMETERS,
|
|
281
|
+
async execute(_toolCallId: string, params: Record<string, any>) {
|
|
282
|
+
try {
|
|
283
|
+
const prompt = typeof params?.prompt === "string" ? params.prompt.trim() : "";
|
|
284
|
+
if (prompt === "") return textResult("pactive_schedule 失败:prompt 必填", { error: true });
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
const spec = parseWhen(params?.when, now);
|
|
287
|
+
if (!spec) {
|
|
288
|
+
return textResult(`pactive_schedule 失败:无法解析 when(${String(params?.when)})`, { error: true });
|
|
289
|
+
}
|
|
290
|
+
const core = await loadCore();
|
|
291
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
292
|
+
const next = await mutateState(core, paths, (state) => {
|
|
293
|
+
const out = core.schedule.add(
|
|
294
|
+
state,
|
|
295
|
+
{ ...spec, prompt, content: prompt, owner: state.identity?.agentId },
|
|
296
|
+
now,
|
|
297
|
+
);
|
|
298
|
+
if (out && typeof out === "object" && out.state) return out.state;
|
|
299
|
+
return out && Array.isArray(out.tasks) ? out : state;
|
|
300
|
+
});
|
|
301
|
+
const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
|
|
302
|
+
const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
|
|
303
|
+
return textResult(
|
|
304
|
+
JSON.stringify(
|
|
305
|
+
{
|
|
306
|
+
ok: true,
|
|
307
|
+
taskId: created?.id ?? created?.taskId ?? null,
|
|
308
|
+
dueAt: created?.dueAt ?? spec.at ?? null,
|
|
309
|
+
kind: spec.kind,
|
|
310
|
+
when: spec.when,
|
|
311
|
+
tasks: Array.isArray(tasks) ? tasks.length : 0,
|
|
312
|
+
},
|
|
313
|
+
null,
|
|
314
|
+
2,
|
|
315
|
+
),
|
|
316
|
+
{ taskId: created?.id ?? null },
|
|
317
|
+
);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return textResult(`pactive_schedule 失败:${errorText(err)}`, { error: true });
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
pi.registerCommand("pactive", {
|
|
325
|
+
description: "Pactive:status | tasks | schedule <when> <prompt> | help",
|
|
326
|
+
async handler(args: string, ctx: PactiveUiContext) {
|
|
327
|
+
const notify = (message: string, level = "info") => {
|
|
328
|
+
try {
|
|
329
|
+
ctx?.ui?.notify?.(message, level);
|
|
330
|
+
} catch {
|
|
331
|
+
/* the host may not have a UI in non-interactive modes */
|
|
332
|
+
}
|
|
333
|
+
process.stderr.write(`[pactive] ${message}\n`);
|
|
334
|
+
};
|
|
335
|
+
const parts = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
336
|
+
const sub = parts[0] ?? "status";
|
|
337
|
+
try {
|
|
338
|
+
const core = await loadCore();
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
const paths = core.resolvePaths(HOST_KIND, process.env);
|
|
341
|
+
const state = (await readState(core, paths)) ?? core.defaultState();
|
|
342
|
+
if (sub === "status") {
|
|
343
|
+
const idle = core.evaluateIdle
|
|
344
|
+
? core.evaluateIdle(state, now, state.policy ?? {})
|
|
345
|
+
: { idle: false, reason: "requires-action" };
|
|
346
|
+
const guard = core.checkGuard ? core.checkGuard(state, now) : { pass: false, reason: "requires-action" };
|
|
347
|
+
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
348
|
+
notify(
|
|
349
|
+
`idle=${idle.idle} reason=${idle.reason} guard=${guard.pass ? "pass" : "blocked"} ` +
|
|
350
|
+
`(${guard.reason ?? "-"}) tasks=${Array.isArray(tasks) ? tasks.length : 0}`,
|
|
351
|
+
);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (sub === "tasks") {
|
|
355
|
+
const tasks = core.schedule?.list ? core.schedule.list(state) : (state.tasks ?? []);
|
|
356
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
357
|
+
notify(
|
|
358
|
+
list.length === 0
|
|
359
|
+
? "没有定时任务"
|
|
360
|
+
: `pactive: ${list.length} 个定时任务\n` +
|
|
361
|
+
list.map((task: any) => `- [${task?.id ?? "?"}] ${task?.prompt ?? task?.content ?? ""}`).join("\n"),
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (sub === "schedule") {
|
|
366
|
+
const when = parts[1] ?? "10m";
|
|
367
|
+
const prompt = parts.slice(2).join(" ");
|
|
368
|
+
if (prompt === "") {
|
|
369
|
+
notify("用法:/pactive schedule <when> <prompt>", "warning");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const spec = parseWhen(when, now);
|
|
373
|
+
if (!spec) {
|
|
374
|
+
notify(`pactive: 无法解析 when(${when})`, "warning");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const next = await mutateState(core, paths, (current) => {
|
|
378
|
+
const out = core.schedule.add(
|
|
379
|
+
current,
|
|
380
|
+
{ ...spec, prompt, content: prompt, owner: current.identity?.agentId },
|
|
381
|
+
now,
|
|
382
|
+
);
|
|
383
|
+
if (out && typeof out === "object" && out.state) return out.state;
|
|
384
|
+
return out && Array.isArray(out.tasks) ? out : current;
|
|
385
|
+
});
|
|
386
|
+
const tasks = core.schedule?.list ? core.schedule.list(next) : (next.tasks ?? []);
|
|
387
|
+
const created = Array.isArray(tasks) ? tasks[tasks.length - 1] : null;
|
|
388
|
+
notify(`pactive: 已登记定时任务 ${created?.id ?? "-"}(${when})`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
notify("用法:/pactive status | tasks | schedule <when> <prompt> | help", "warning");
|
|
392
|
+
} catch (err) {
|
|
393
|
+
notify(`pactive 失败:${errorText(err)}`, "error");
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Session lifecycle → activity + control tracking.
|
|
399
|
+
pi.on("session_start", () => {
|
|
400
|
+
void markActivity("session-start");
|
|
401
|
+
});
|
|
402
|
+
pi.on("input", () => {
|
|
403
|
+
void markActivity("user");
|
|
404
|
+
});
|
|
405
|
+
pi.on("agent_start", () => {
|
|
406
|
+
void markActivity("agent");
|
|
407
|
+
});
|
|
408
|
+
pi.on("tool_execution_start", () => {
|
|
409
|
+
void markActivity("tool");
|
|
410
|
+
});
|
|
411
|
+
pi.on("message_end", () => {
|
|
412
|
+
void markActivity("agent");
|
|
413
|
+
});
|
|
414
|
+
pi.on("agent_settled", () => {
|
|
415
|
+
void markControlReturned();
|
|
416
|
+
});
|
|
417
|
+
pi.on("session_shutdown", () => {
|
|
418
|
+
void markControlReturned();
|
|
419
|
+
});
|
|
420
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pactive
|
|
3
|
+
description: >-
|
|
4
|
+
Correct usage of the pactive proactive-assistant extension (tools pactive_status / pactive_schedule /
|
|
5
|
+
pactive_tell / pactive_history and the "/pactive" command family). Use when the task involves checking
|
|
6
|
+
whether the idle trigger fired or why it did not, scheduling work for later (one-shot or
|
|
7
|
+
repeating), reading the pactive event ledger, or sending a message to another session on the same
|
|
8
|
+
machine. Covers the semantics traps (queued is not delivered, idle is disabled by default,
|
|
9
|
+
human-gated config needs CLI --confirm, the Stop hook blocks at most once per turn) and the
|
|
10
|
+
"when" syntax (10m / 18:30 / ISO 8601 / every 1h).
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# pactive:怎么正确用它
|
|
14
|
+
|
|
15
|
+
pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定时任务**、**跨会话消息**与**事件账本**。
|
|
16
|
+
|
|
17
|
+
## 工具与命令
|
|
18
|
+
|
|
19
|
+
| 入口 | 作用 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `pactive_status` / `/pactive status` | 空闲判定与 `reason`、护栏四闸门、定时任务数、最近活动(只读) |
|
|
22
|
+
| `pactive_schedule` / `/pactive schedule <when> <prompt>` | 登记一个定时任务(真正落盘,重启后仍在) |
|
|
23
|
+
| `pactive_tell <to> <body>` | 给同机另一个会话投递消息(离线时排队,不伪装成功) |
|
|
24
|
+
| `pactive_history [n]` | 读事件账本尾部:触发 → 投递 → 回应 → 结账 |
|
|
25
|
+
| `/pactive tasks` | 列出待触发的定时任务 |
|
|
26
|
+
|
|
27
|
+
## 关键语义(别踩)
|
|
28
|
+
|
|
29
|
+
- **默认不打扰**:`idle.enabled` 默认 `false`。没打开开关,空闲永远不会触发主动行为。
|
|
30
|
+
- **`reason` 是枚举**:`idle | disabled | no-control | busy | threshold | quiet | hourly-limit | min-interval | cooldown | requires-action`。
|
|
31
|
+
没触发时先看 `reason`,不要猜。
|
|
32
|
+
- **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
|
|
33
|
+
- **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
|
|
34
|
+
- **配置是人工闸门**:`config` 只能由人用 CLI `--confirm` 改;模型/MCP 侧改配置一律得到
|
|
35
|
+
`need_human_confirmation`,且配置不变。
|
|
36
|
+
- **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
|
|
37
|
+
|
|
38
|
+
## `when` 语法
|
|
39
|
+
|
|
40
|
+
`10m` / `2h` / `30s`(相对时长)、`18:30`(当天时刻,已过则顺延到明天)、
|
|
41
|
+
ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
|
|
42
|
+
|
|
43
|
+
## 排障
|
|
44
|
+
|
|
45
|
+
- 状态面板里 `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
|
|
46
|
+
- 任务登记了但没触发 → 先看 `pactive_history` 里有没有 `triggered` 记录;没有说明调度器没跑到。
|
|
47
|
+
- 消息一直是 `queued` → 对端不在线;看 `pactive_status` 的 peers。
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pactive
|
|
3
|
+
description: >-
|
|
4
|
+
Correct usage of the pactive proactive-assistant extension (tools pactive_status / pactive_schedule /
|
|
5
|
+
pactive_tell / pactive_history and the "/pactive" command family). Use when the task involves checking
|
|
6
|
+
whether the idle trigger fired or why it did not, scheduling work for later (one-shot or
|
|
7
|
+
repeating), reading the pactive event ledger, or sending a message to another session on the same
|
|
8
|
+
machine. Covers the semantics traps (queued is not delivered, idle is disabled by default,
|
|
9
|
+
human-gated config needs CLI --confirm, the Stop hook blocks at most once per turn) and the
|
|
10
|
+
"when" syntax (10m / 18:30 / ISO 8601 / every 1h).
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# pactive:怎么正确用它
|
|
14
|
+
|
|
15
|
+
pactive 给会话加**主动机制**:空闲判定(到点才动手)、**定时任务**、**跨会话消息**与**事件账本**。
|
|
16
|
+
|
|
17
|
+
## 工具与命令
|
|
18
|
+
|
|
19
|
+
| 入口 | 作用 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `pactive_status` / `/pactive status` | 空闲判定与 `reason`、护栏四闸门、定时任务数、最近活动(只读) |
|
|
22
|
+
| `pactive_schedule` / `/pactive schedule <when> <prompt>` | 登记一个定时任务(真正落盘,重启后仍在) |
|
|
23
|
+
| `pactive_tell <to> <body>` | 给同机另一个会话投递消息(离线时排队,不伪装成功) |
|
|
24
|
+
| `pactive_history [n]` | 读事件账本尾部:触发 → 投递 → 回应 → 结账 |
|
|
25
|
+
| `/pactive tasks` | 列出待触发的定时任务 |
|
|
26
|
+
|
|
27
|
+
## 关键语义(别踩)
|
|
28
|
+
|
|
29
|
+
- **默认不打扰**:`idle.enabled` 默认 `false`。没打开开关,空闲永远不会触发主动行为。
|
|
30
|
+
- **`reason` 是枚举**:`idle | disabled | no-control | busy | threshold | quiet | hourly-limit | min-interval | cooldown | requires-action`。
|
|
31
|
+
没触发时先看 `reason`,不要猜。
|
|
32
|
+
- **四闸门缺一不可**:控制权已交回 + 不忙 + 超过阈值 + 护栏通过,才可能触发。
|
|
33
|
+
- **`queued` ≠ 已送达**:对端离线时消息进 outbox,等对端上线再补投;回执写 `queued` 就是还没送到。
|
|
34
|
+
- **配置是人工闸门**:`config` 只能由人用 CLI `--confirm` 改;模型/MCP 侧改配置一律得到
|
|
35
|
+
`need_human_confirmation`,且配置不变。
|
|
36
|
+
- **Stop hook 最多阻断一次**:同一轮里只阻断一次,避免死循环;`UserPromptSubmit` 会重置这个计数。
|
|
37
|
+
|
|
38
|
+
## `when` 语法
|
|
39
|
+
|
|
40
|
+
`10m` / `2h` / `30s`(相对时长)、`18:30`(当天时刻,已过则顺延到明天)、
|
|
41
|
+
ISO 8601 时间戳(绝对时刻)、`every 1h`(周期)。缺省 `10m`。
|
|
42
|
+
|
|
43
|
+
## 排障
|
|
44
|
+
|
|
45
|
+
- 状态面板里 `reason=disabled` → 开关没开;`reason=busy` → 宿主还在忙;`reason=threshold` → 还没到阈值。
|
|
46
|
+
- 任务登记了但没触发 → 先看 `pactive_history` 里有没有 `triggered` 记录;没有说明调度器没跑到。
|
|
47
|
+
- 消息一直是 `queued` → 对端不在线;看 `pactive_status` 的 peers。
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const ACTIVITY_KINDS = ['user', 'agent', 'session-start', 'session-end', 'tool'];
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Record activity on the shared timeline. Mutates and returns the state.
|
|
5
|
+
* Only user and agent activity open a new activity epoch.
|
|
6
|
+
*/
|
|
7
|
+
export function markActivity(state, kind, now = Date.now()) {
|
|
8
|
+
if (!ACTIVITY_KINDS.includes(kind)) {
|
|
9
|
+
throw new Error(`unknown activity kind: ${kind}`);
|
|
10
|
+
}
|
|
11
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
12
|
+
switch (kind) {
|
|
13
|
+
case 'user':
|
|
14
|
+
state.lastUserActivityAt = ts;
|
|
15
|
+
state.lastActivityAt = ts;
|
|
16
|
+
state.epoch += 1;
|
|
17
|
+
state.controlReturned = false;
|
|
18
|
+
break;
|
|
19
|
+
case 'agent':
|
|
20
|
+
state.lastAgentActivityAt = ts;
|
|
21
|
+
state.lastActivityAt = ts;
|
|
22
|
+
state.epoch += 1;
|
|
23
|
+
state.controlReturned = false;
|
|
24
|
+
break;
|
|
25
|
+
case 'tool':
|
|
26
|
+
state.lastAgentActivityAt = ts;
|
|
27
|
+
state.lastActivityAt = ts;
|
|
28
|
+
state.controlReturned = false;
|
|
29
|
+
break;
|
|
30
|
+
case 'session-start':
|
|
31
|
+
state.lastActivityAt = ts;
|
|
32
|
+
state.controlReturned = false;
|
|
33
|
+
state.busy = false;
|
|
34
|
+
break;
|
|
35
|
+
case 'session-end':
|
|
36
|
+
state.lastActivityAt = ts;
|
|
37
|
+
state.controlReturned = false;
|
|
38
|
+
state.busy = false;
|
|
39
|
+
break;
|
|
40
|
+
default:
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The host gave control back: the agent is idle and may be prompted. */
|
|
47
|
+
export function markControlReturned(state, now = Date.now()) {
|
|
48
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
49
|
+
state.controlReturned = true;
|
|
50
|
+
state.busy = false;
|
|
51
|
+
state.lastActivityAt = ts;
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getActivity(state, now = Date.now()) {
|
|
56
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
57
|
+
const lastActivityAt = Number.isFinite(state.lastActivityAt) ? state.lastActivityAt : 0;
|
|
58
|
+
return {
|
|
59
|
+
lastActivityAt,
|
|
60
|
+
lastUserActivityAt: Number.isFinite(state.lastUserActivityAt) ? state.lastUserActivityAt : 0,
|
|
61
|
+
lastAgentActivityAt: Number.isFinite(state.lastAgentActivityAt) ? state.lastAgentActivityAt : 0,
|
|
62
|
+
epoch: Number.isFinite(state.epoch) ? state.epoch : 0,
|
|
63
|
+
busy: state.busy === true,
|
|
64
|
+
controlReturned: state.controlReturned === true,
|
|
65
|
+
idleForMs: ts - lastActivityAt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { pactiveError } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
export const CONFIG_SCHEMA = {
|
|
4
|
+
'idle.enabled': { type: 'boolean', default: false, humanGated: true },
|
|
5
|
+
'idle.thresholdMs': { type: 'number', default: 900000, humanGated: true, min: 0 },
|
|
6
|
+
'policy.quietStart': { type: 'string|null', default: null, humanGated: true },
|
|
7
|
+
'policy.quietEnd': { type: 'string|null', default: null, humanGated: true },
|
|
8
|
+
'policy.hourlyLimit': { type: 'number', default: 6, humanGated: true, min: 0 },
|
|
9
|
+
'policy.minIntervalMs': { type: 'number', default: 600000, humanGated: true, min: 0 },
|
|
10
|
+
'policy.cooldownMs': { type: 'number', default: 1800000, humanGated: true, min: 0 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const config = {
|
|
14
|
+
get(state, key) {
|
|
15
|
+
const spec = specOf(key);
|
|
16
|
+
const current = readPath(state, key);
|
|
17
|
+
return current === undefined ? spec.default : current;
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
describe() {
|
|
21
|
+
const defaults = {};
|
|
22
|
+
const humanGatedKeys = [];
|
|
23
|
+
for (const [key, spec] of Object.entries(CONFIG_SCHEMA)) {
|
|
24
|
+
defaults[key] = spec.default;
|
|
25
|
+
if (spec.humanGated) humanGatedKeys.push(key);
|
|
26
|
+
}
|
|
27
|
+
return { schema: CONFIG_SCHEMA, defaults, humanGatedKeys };
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/** Gated keys only change with an explicit confirmation; without one nothing is written. */
|
|
31
|
+
setHumanGated(state, key, value, options = {}) {
|
|
32
|
+
const spec = specOf(key);
|
|
33
|
+
const confirm = options && options.confirm === true;
|
|
34
|
+
if (spec.humanGated && !confirm) {
|
|
35
|
+
return { ok: false, key, reason: 'need_human_confirmation' };
|
|
36
|
+
}
|
|
37
|
+
if (!validValue(spec, value)) {
|
|
38
|
+
return { ok: false, key, reason: 'invalid-value' };
|
|
39
|
+
}
|
|
40
|
+
writePath(state, key, value);
|
|
41
|
+
return { ok: true, key, value };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function specOf(key) {
|
|
46
|
+
const spec = Object.prototype.hasOwnProperty.call(CONFIG_SCHEMA, key) ? CONFIG_SCHEMA[key] : null;
|
|
47
|
+
if (!spec) {
|
|
48
|
+
throw pactiveError('CONFIG_KEY_UNKNOWN', `unknown config key: ${key}`);
|
|
49
|
+
}
|
|
50
|
+
return spec;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validValue(spec, value) {
|
|
54
|
+
if (value === null) return spec.type === 'string|null';
|
|
55
|
+
if (spec.type === 'boolean') return typeof value === 'boolean';
|
|
56
|
+
if (spec.type === 'number') {
|
|
57
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
|
|
58
|
+
if (typeof spec.min === 'number' && value < spec.min) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (spec.type === 'string|null') return typeof value === 'string';
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readPath(state, key) {
|
|
66
|
+
const parts = key.split('.');
|
|
67
|
+
let current = state;
|
|
68
|
+
for (const part of parts) {
|
|
69
|
+
if (current === null || typeof current !== 'object') return undefined;
|
|
70
|
+
current = current[part];
|
|
71
|
+
}
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writePath(state, key, value) {
|
|
76
|
+
const parts = key.split('.');
|
|
77
|
+
let current = state;
|
|
78
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
79
|
+
const part = parts[i];
|
|
80
|
+
if (!current[part] || typeof current[part] !== 'object') current[part] = {};
|
|
81
|
+
current = current[part];
|
|
82
|
+
}
|
|
83
|
+
current[parts[parts.length - 1]] = value;
|
|
84
|
+
}
|