pi-feats 0.1.4 → 0.1.5
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/README.md +43 -5
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +16 -6
- package/extensions/api-server/application-store.ts +21 -8
- package/extensions/api-server/profile-store.ts +33 -10
- package/extensions/api-server/server.ts +43 -4
- package/extensions/cli-resources.ts +20 -20
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/console-page.tsx +3 -0
- package/extensions/pi-console-webui/components/console.tsx +9 -5
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +5 -5
- package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +3 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +53 -17
- package/extensions/pulse/index.ts +128 -14
- package/extensions/pulse/store.ts +108 -16
- package/package.json +3 -1
- package/skills/profile-ssh/SKILL.md +65 -0
package/extensions/profiles.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Box, render, Text } from "ink";
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { copyFile, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, join, resolve } from "node:path";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
@@ -27,7 +27,7 @@ const profilesDir = () => join(rootAgentDir(), "profiles");
|
|
|
27
27
|
const profileDir = (name: string) => join(profilesDir(), name);
|
|
28
28
|
const validProfileName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name);
|
|
29
29
|
const reservedProfileNames = new Set([
|
|
30
|
-
"add", "create", "delete", "disable", "enable", "extensions", "guardrails", "list", "open", "packages", "profile", "pulse", "remove", "rename", "resume", "sessions", "skills", "tools", "validate",
|
|
30
|
+
"add", "create", "delete", "disable", "enable", "extensions", "guardrails", "list", "open", "packages", "profile", "pulse", "remove", "rename", "resume", "sessions", "skills", "ssh", "tools", "validate",
|
|
31
31
|
...REMOTE_COMMAND_NAMES,
|
|
32
32
|
]);
|
|
33
33
|
|
|
@@ -99,13 +99,35 @@ async function readJson(path: string): Promise<ProfileSettings> {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
async function packageSkillRoots(root: string): Promise<string[]> {
|
|
103
|
+
let settings: ProfileSettings = {};
|
|
104
|
+
try { settings = await readJson(join(root, "settings.json")); } catch { return []; }
|
|
105
|
+
const roots: string[] = [];
|
|
106
|
+
if (!Array.isArray(settings.packages)) return roots;
|
|
107
|
+
for (const entry of settings.packages) {
|
|
108
|
+
const source = typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry;
|
|
109
|
+
if (typeof source !== "string") continue;
|
|
110
|
+
const packagePath = packageInstallPath(root, source);
|
|
111
|
+
if (!packagePath) continue;
|
|
112
|
+
try {
|
|
113
|
+
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { pi?: { skills?: unknown } };
|
|
114
|
+
if (Array.isArray(manifest.pi?.skills)) for (const skill of manifest.pi.skills) if (typeof skill === "string" && existsSync(join(packagePath, skill))) roots.push(join(packagePath, skill));
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
return roots;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function sharedResources(root: string, policy: ProfilePolicy = {}, localSkillsDir?: string) {
|
|
121
|
+
// Extensions are loaded through Pi package settings. Named profiles need
|
|
122
|
+
// package Skill paths materialized explicitly because their settings omit
|
|
123
|
+
// packages and Pi otherwise only discovers workspace-local skills.
|
|
124
|
+
const selectedSharedSkills = !policy.enabledSkills || policy.enabledSkills.includes("*");
|
|
125
|
+
const packageSkills = policy.skillSources?.shared === false ? [] : (await packageSkillRoots(root)).flatMap((skillRoot) => selectedSharedSkills ? [skillRoot] : policy.enabledSkills!.map((name) => join(skillRoot, name)).filter(existsSync));
|
|
105
126
|
return {
|
|
106
127
|
skills: [
|
|
107
128
|
...(policy.skillSources?.profile === true && localSkillsDir ? (!policy.enabledProfileSkills || policy.enabledProfileSkills.includes("*") ? [localSkillsDir] : policy.enabledProfileSkills.map((name) => join(localSkillsDir, name)).filter(existsSync)) : []),
|
|
108
|
-
...(policy.skillSources?.shared !== false ? (
|
|
129
|
+
...(policy.skillSources?.shared !== false ? (selectedSharedSkills ? [join(root, "skills")] : policy.enabledSkills!.map((name) => join(root, "skills", name)).filter(existsSync)) : []),
|
|
130
|
+
...packageSkills,
|
|
109
131
|
],
|
|
110
132
|
prompts: [join(root, "prompts")],
|
|
111
133
|
themes: [join(root, "themes")],
|
|
@@ -129,14 +151,19 @@ function packageInstallPath(root: string, source: string): string | undefined {
|
|
|
129
151
|
return existsSync(join(path, "package.json")) ? path : undefined;
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
|
|
154
|
+
const requiredRuntimeExtensions = new Set(["profiles", "api-server", "conversation-search"]);
|
|
155
|
+
|
|
156
|
+
export async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<string[]> {
|
|
133
157
|
const sources = new Set<string>();
|
|
134
158
|
const enabled = base.profile?.enabledExtensions;
|
|
135
159
|
const extensionName = (path: string) => basename(path).replace(/\.(?:ts|js)$/, "");
|
|
136
160
|
const add = (path: string) => {
|
|
137
161
|
if (!existsSync(path)) return;
|
|
138
162
|
const name = extensionName(path);
|
|
139
|
-
|
|
163
|
+
// Profiles are workspaces, but these extensions are core runtime
|
|
164
|
+
// capabilities and must cross the default-runtime boundary even when a
|
|
165
|
+
// profile limits optional extensions.
|
|
166
|
+
if (requiredRuntimeExtensions.has(name) || !enabled || enabled.includes("*") || enabled.includes(name)) sources.add(path);
|
|
140
167
|
};
|
|
141
168
|
if (Array.isArray(base.packages)) for (const entry of base.packages) {
|
|
142
169
|
const source = typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry;
|
|
@@ -162,11 +189,11 @@ async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<
|
|
|
162
189
|
return [...sources];
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
function profileSettings(root: string, base: ProfileSettings, localSkillsDir: string): ProfileSettings {
|
|
192
|
+
async function profileSettings(root: string, base: ProfileSettings, localSkillsDir: string): Promise<ProfileSettings> {
|
|
166
193
|
const { packages: _packages, extensions: _extensions, ...profileBase } = base;
|
|
167
194
|
return {
|
|
168
195
|
...profileBase,
|
|
169
|
-
...sharedResources(root, { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false } }, localSkillsDir),
|
|
196
|
+
...await sharedResources(root, { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false } }, localSkillsDir),
|
|
170
197
|
defaultTools: ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"],
|
|
171
198
|
profile: {
|
|
172
199
|
enabledTools: ["*"],
|
|
@@ -227,6 +254,12 @@ async function removeProfileRuntimeArtifacts(destination: string) {
|
|
|
227
254
|
await Promise.all(["extensions", "git", "npm", "node_modules"].map((name) => rm(join(destination, name), { recursive: true, force: true })));
|
|
228
255
|
}
|
|
229
256
|
|
|
257
|
+
async function linkSharedModels(root: string, destination: string): Promise<void> {
|
|
258
|
+
const source = join(root, "models.json"), target = join(destination, "models.json");
|
|
259
|
+
await rm(target, { force: true });
|
|
260
|
+
if (existsSync(source)) await symlink(source, target);
|
|
261
|
+
}
|
|
262
|
+
|
|
230
263
|
async function createProfile(name: string) {
|
|
231
264
|
if (!validProfileName(name)) fail("invalid name; use letters, numbers, hyphens, or underscores (max. 64 characters).");
|
|
232
265
|
if (name === "default") fail("default is the primary profile and cannot be created.");
|
|
@@ -239,7 +272,7 @@ async function createProfile(name: string) {
|
|
|
239
272
|
await mkdir(join(destination, "sessions"), { recursive: true });
|
|
240
273
|
const baseSettingsPath = join(root, "settings.json");
|
|
241
274
|
const base = existsSync(baseSettingsPath) ? await readJson(baseSettingsPath) : {};
|
|
242
|
-
await writeFile(join(destination, "settings.json"), `${JSON.stringify({ ...profileSettings(root, base, join(destination, "skills")), sandbox: true }, null, 2)}\n`);
|
|
275
|
+
await writeFile(join(destination, "settings.json"), `${JSON.stringify({ ...await profileSettings(root, base, join(destination, "skills")), sandbox: true }, null, 2)}\n`);
|
|
243
276
|
await ensureProfileSandbox(destination, resolve(process.argv[1]));
|
|
244
277
|
await writeFile(join(destination, "SOUL.md"), "");
|
|
245
278
|
await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n");
|
|
@@ -247,10 +280,7 @@ async function createProfile(name: string) {
|
|
|
247
280
|
const authPath = join(root, "auth.json");
|
|
248
281
|
if (existsSync(authPath)) await copyFile(authPath, join(destination, "auth.json"));
|
|
249
282
|
|
|
250
|
-
|
|
251
|
-
if (existsSync(modelsPath)) {
|
|
252
|
-
await symlink(modelsPath, join(destination, "models.json"));
|
|
253
|
-
}
|
|
283
|
+
await linkSharedModels(root, destination);
|
|
254
284
|
|
|
255
285
|
await writeStdout(`Profile '${name}' created at ${destination}\n`);
|
|
256
286
|
}
|
|
@@ -276,6 +306,9 @@ async function handleProfileCommand(args: string[]) {
|
|
|
276
306
|
// Accept the natural profile-first forms too. This is particularly useful
|
|
277
307
|
// for remote operation, where a session ID is copied from `sessions list`.
|
|
278
308
|
if (action && !reservedProfileNames.has(action.toLowerCase())) {
|
|
309
|
+
// Profile-first shorthand for SSH registration. Keep the explicit
|
|
310
|
+
// `pi profile <name> ssh add <alias>` form as the canonical spelling.
|
|
311
|
+
if (args[2] === "add" && args.length === 4) return reexecWithProfile(action, ["ssh", "add", args[3]]);
|
|
279
312
|
if (args[2] === "resume" && args.length === 3) return reexecWithProfile(action, ["--resume"]);
|
|
280
313
|
if ((args[2] === "resume" || args[2] === "open") && args.length === 4) return reexecWithProfile(action, ["--session", args[3]]);
|
|
281
314
|
return reexecWithProfile(action, args.slice(2));
|
|
@@ -335,9 +368,10 @@ async function syncProfileResources(name: string) {
|
|
|
335
368
|
delete settings.packages;
|
|
336
369
|
delete settings.extensions;
|
|
337
370
|
if (settings.profile) delete settings.profile.enabledExtensions;
|
|
338
|
-
Object.assign(settings, sharedResources(root, settings.profile, join(profileDir(name), "skills")));
|
|
371
|
+
Object.assign(settings, await sharedResources(root, settings.profile, join(profileDir(name), "skills")));
|
|
339
372
|
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
340
373
|
await removeProfileRuntimeArtifacts(profileDir(name));
|
|
374
|
+
await linkSharedModels(root, profileDir(name));
|
|
341
375
|
}
|
|
342
376
|
|
|
343
377
|
async function reexecWithProfile(name: string, args: string[]) {
|
|
@@ -435,7 +469,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
435
469
|
const enabledTools = settings.profile?.enabledTools;
|
|
436
470
|
if (!enabledTools || enabledTools.includes("*")) return;
|
|
437
471
|
const allowed = new Set(enabledTools);
|
|
438
|
-
|
|
472
|
+
// Conversation search is a package-wide profile capability, even when a
|
|
473
|
+
// profile limits optional tools to an explicit allow-list.
|
|
474
|
+
pi.setActiveTools(pi.getAllTools().filter((tool) => allowed.has(tool.name) || tool.name === "conversation_search").map((tool) => tool.name));
|
|
439
475
|
});
|
|
440
476
|
|
|
441
477
|
pi.on("before_agent_start", async (event) => {
|
|
@@ -1,44 +1,158 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { openSync } from "node:fs";
|
|
5
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
9
|
import { PulseStore } from "./store.ts";
|
|
10
|
+
import { ApplicationStore } from "../api-server/application-store.ts";
|
|
9
11
|
|
|
10
12
|
const root = () => process.env.PI_PROFILE_ROOT ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
11
|
-
const dbPath = () => join(root(), "pulse.db");
|
|
12
|
-
|
|
13
|
+
const dbPath = () => join(root(), "pulse.db");
|
|
14
|
+
const statePath = () => join(root(), "pulse-tick.state.json");
|
|
15
|
+
const logPath = () => join(root(), "pulse-tick.log");
|
|
16
|
+
const TICK_INTERVAL_MS = 60_000;
|
|
17
|
+
type State = { pid: number; owner?: string; startedAt: string };
|
|
13
18
|
const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
14
19
|
async function json<T>(path: string): Promise<T | undefined> { try { return JSON.parse(await readFile(path, "utf8")) as T; } catch { return undefined; } }
|
|
15
|
-
async function write(path: string, value: unknown) { await mkdir(root(), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); }
|
|
20
|
+
async function write(path: string, value: unknown, exclusive = false) { await mkdir(root(), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: exclusive ? "wx" : "w" }); }
|
|
21
|
+
|
|
22
|
+
/** The SQLite lease is the singleton authority; the state file is diagnostic only. */
|
|
23
|
+
async function acquireTickLock(store: PulseStore): Promise<{ owner: string; release: () => Promise<void> } | undefined> {
|
|
24
|
+
const owner = randomUUID();
|
|
25
|
+
if (!store.claimTickLease(owner)) return undefined;
|
|
26
|
+
const state: State = { pid: process.pid, owner, startedAt: new Date().toISOString() };
|
|
27
|
+
await write(statePath(), state);
|
|
28
|
+
return { owner, release: async () => {
|
|
29
|
+
store.releaseTickLease(owner);
|
|
30
|
+
const current = await json<State>(statePath());
|
|
31
|
+
if (current?.owner === owner) await unlink(statePath()).catch(() => {});
|
|
32
|
+
} };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function profileModelArgs(profile: string, agentDir = root()): Promise<string[]> {
|
|
36
|
+
const settingsPath = profile === "default" ? join(agentDir, "settings.json") : join(agentDir, "profiles", profile, "settings.json");
|
|
37
|
+
const settings = await json<Record<string, unknown>>(settingsPath);
|
|
38
|
+
const provider = typeof settings?.defaultProvider === "string" ? settings.defaultProvider.trim() : "";
|
|
39
|
+
const model = typeof settings?.defaultModel === "string" ? settings.defaultModel.trim() : "";
|
|
40
|
+
return provider && model ? ["--provider", provider, "--model", model] : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sessionDirectory(profile: string): string { return join(root(), profile === "default" ? "sessions" : join("profiles", profile, "sessions")); }
|
|
44
|
+
export function pulseSessionId(type: "cron" | "heartbeat", date = new Date()): string { return `pulse-${type}_${date.toISOString().slice(0, 16).replace("T", "-").replace(":", "-")}`; }
|
|
45
|
+
|
|
16
46
|
async function sessionFile(profile: string, id: string): Promise<{ file: string; cwd: string } | undefined> {
|
|
17
|
-
const directory =
|
|
47
|
+
const directory = sessionDirectory(profile);
|
|
18
48
|
const visit = async (path: string): Promise<string | undefined> => { for (const entry of await readdir(path, { withFileTypes: true }).catch(() => [])) { const candidate = join(path, entry.name); if (entry.isDirectory()) { const match = await visit(candidate); if (match) return match; } else if (entry.isFile() && entry.name.endsWith(`_${id}.jsonl`)) return candidate; } };
|
|
19
49
|
const file = await visit(directory); if (!file) return undefined;
|
|
20
50
|
const header = JSON.parse((await readFile(file, "utf8")).split("\n", 1)[0]) as { cwd?: string };
|
|
21
51
|
return { file, cwd: header.cwd || process.cwd() };
|
|
22
52
|
}
|
|
53
|
+
async function createPulseSession(profile: string, type: "cron" | "heartbeat", cwd: string): Promise<{ id: string; file: string }> {
|
|
54
|
+
const base = pulseSessionId(type); let id = base, suffix = 2;
|
|
55
|
+
while (await sessionFile(profile, id)) id = `${base}-${String(suffix++).padStart(2, "0")}`;
|
|
56
|
+
const session = SessionManager.create(cwd, sessionDirectory(profile), { id });
|
|
57
|
+
session.appendSessionInfo(id);
|
|
58
|
+
const file = session.getSessionFile();
|
|
59
|
+
if (!file) throw new Error("Unable to create the Pulse execution session.");
|
|
60
|
+
return { id, file };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function appendPulseResult(profile: string, sessionId: string, content: string, details: Record<string, unknown>): Promise<void> {
|
|
64
|
+
const target = await sessionFile(profile, sessionId);
|
|
65
|
+
if (!target) throw new Error(`Session '${sessionId}' was not found for profile '${profile}'.`);
|
|
66
|
+
SessionManager.open(target.file, sessionDirectory(profile), target.cwd).appendCustomMessageEntry("pulse", content, true, details);
|
|
67
|
+
}
|
|
68
|
+
|
|
23
69
|
function table(profile?: string) { const rows = new PulseStore(dbPath()).list(profile); const columns: Array<[string, number]> = [["STATUS", 8], ["TYPE", 9], ["NAME", 20], ["SCHEDULE", 20], ["THREAD SESSION", 24], ["NEXT RUN", 20], ["LAST RUN", 20]]; const clip = (v: string, n: number) => v.length > n ? `${v.slice(0, n - 1)}…` : v; const row = (values: string[]) => `│ ${values.map((v, i) => clip(v, columns[i][1]).padEnd(columns[i][1])).join(" │ ")} │`; const line = `┼${columns.map(([, n]) => "─".repeat(n + 2)).join("┼")}┼`; console.log(line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐")); console.log(row(columns.map(([n]) => n))); console.log(line); for (const item of rows) { const color = item.enabled ? "\x1b[32m" : "\x1b[38;2;245;194;215m"; console.log(`${color}${row([item.enabled ? "enabled" : "disabled", item.type, item.name, item.schedule, item.thread_session_id, item.nextRunAt ?? "—", item.lastRunAt ?? "—"])}\x1b[0m`); } if (!rows.length) console.log(row(["—", "—", "No pulses configured", "—", "—", "—", "—"])); console.log(line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘")); }
|
|
24
|
-
async function start() { const current = await json<State>(statePath()); if (current && alive(current.pid)) return console.log(`Pulse tick is already running (PID ${current.pid}).`);
|
|
25
|
-
async function stop() { const current = await json<State>(statePath()); if (!current || !alive(current.pid))
|
|
70
|
+
async function start() { const current = await json<State>(statePath()); if (current && alive(current.pid)) return console.log(`Pulse tick is already running (PID ${current.pid}).`); const fd = openSync(logPath(), "a"); const child = spawn("sh", ["-c", "tail -f /dev/null | \"$@\"", "pi-pulse-tick", process.execPath, process.argv[1], "pulse", "tick"], { cwd: process.cwd(), detached: true, stdio: ["ignore", fd, fd], env: { ...process.env, PI_PULSE_TICK: "1" } }); child.unref(); console.log(`Pulse tick starting (PID ${child.pid}).`); }
|
|
71
|
+
async function stop() { const current = await json<State>(statePath()); if (!current || !alive(current.pid)) return console.log("Pulse tick is not running."); try { process.kill(-current.pid, "SIGTERM"); } catch { process.kill(current.pid, "SIGTERM"); } console.log(`Pulse tick stopping (PID ${current.pid}).`); }
|
|
72
|
+
async function restart() {
|
|
73
|
+
const current = await json<State>(statePath());
|
|
74
|
+
await stop();
|
|
75
|
+
if (current) for (let attempt = 0; attempt < 50 && alive(current.pid); attempt++) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
76
|
+
if (current && alive(current.pid)) throw new Error(`Pulse tick (PID ${current.pid}) did not stop within 5 seconds.`);
|
|
77
|
+
await start();
|
|
78
|
+
}
|
|
26
79
|
async function status() { const state = await json<State>(statePath()); const store = new PulseStore(dbPath()); const enabled = store.list().filter((item) => item.enabled).length; console.log(!state || !alive(state.pid) ? `Pulse tick: stopped (${enabled} enabled pulses)` : `Pulse tick: running (PID ${state.pid}, ${enabled} enabled pulses, since ${state.startedAt})`); }
|
|
27
|
-
async function
|
|
28
|
-
|
|
80
|
+
async function executePulse(store: PulseStore, pulse: Awaited<ReturnType<PulseStore["claimDue"]>>[number], owner: string) {
|
|
81
|
+
const renew = setInterval(() => { store.renewClaim(pulse.pulse.id, pulse.runId, owner); store.claimTickLease(owner); }, 30_000);
|
|
82
|
+
try {
|
|
83
|
+
const handoff = pulse.pulse.type === "heartbeat" ? store.handoff(pulse.pulse.id) : "";
|
|
84
|
+
const message = `[Pulse: ${pulse.pulse.name}]\n${pulse.pulse.prompt}${handoff ? `\n\nPrevious heartbeat state (private):\n${handoff}` : ""}`;
|
|
85
|
+
const source = pulse.pulse.thread_session_id ? await sessionFile(pulse.pulse.profile, pulse.pulse.thread_session_id) : undefined;
|
|
86
|
+
if (pulse.pulse.thread_session_id && !source) throw new Error(`Thread session '${pulse.pulse.thread_session_id}' was not found for profile '${pulse.pulse.profile}'.`);
|
|
87
|
+
const execution = await createPulseSession(pulse.pulse.profile, pulse.pulse.type, source?.cwd ?? process.cwd());
|
|
88
|
+
store.setRunSession(pulse.runId, execution.id);
|
|
89
|
+
const modelArgs = await profileModelArgs(pulse.pulse.profile);
|
|
90
|
+
const result = await new Promise<string>((resolve, reject) => {
|
|
91
|
+
const child = spawn(process.execPath, [process.argv[1], "profile", pulse.pulse.profile, "--session", execution.file, ...modelArgs, "--print", message], { cwd: source?.cwd ?? process.cwd(), env: { ...process.env, PI_PULSE_TICK: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
92
|
+
let output = "", error = "";
|
|
93
|
+
child.stdout.on("data", (data) => output += String(data)); child.stderr.on("data", (data) => error += String(data));
|
|
94
|
+
child.once("exit", (code) => code === 0 ? resolve(output.trim()) : reject(new Error(error.trim() || `Pi exited with ${code}`)));
|
|
95
|
+
child.once("error", reject);
|
|
96
|
+
});
|
|
97
|
+
const content = `[Pulse: ${pulse.pulse.name}]\n${result}`;
|
|
98
|
+
const details = { pulseId: pulse.pulse.id, pulseName: pulse.pulse.name, runId: pulse.runId, executionSessionId: execution.id, type: pulse.pulse.type, completedAt: new Date().toISOString() };
|
|
99
|
+
if (pulse.pulse.thread_session_id) await appendPulseResult(pulse.pulse.profile, pulse.pulse.thread_session_id, content, details);
|
|
100
|
+
if (pulse.pulse.insertIntoApiSession) {
|
|
101
|
+
const applications = new ApplicationStore(join(root(), "applications.db"));
|
|
102
|
+
for (const active of applications.activeSessions({ profile: pulse.pulse.profile })) {
|
|
103
|
+
try { await appendPulseResult(pulse.pulse.profile, active.sessionId, content, { ...details, application: active.application }); }
|
|
104
|
+
catch (error) { console.error(`Unable to insert Pulse '${pulse.pulse.name}' into Application '${active.application}' session '${active.sessionId}': ${error instanceof Error ? error.message : String(error)}`); }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
store.complete(pulse.pulse, pulse.runId, result, pulse.pulse.type === "heartbeat" ? result.slice(-8000) : undefined, owner);
|
|
108
|
+
} catch (error) { store.fail(pulse.pulse, pulse.runId, error instanceof Error ? error.message : String(error), owner); }
|
|
109
|
+
finally { clearInterval(renew); }
|
|
110
|
+
}
|
|
111
|
+
async function tick() {
|
|
112
|
+
const store = new PulseStore(dbPath());
|
|
113
|
+
const lock = await acquireTickLock(store);
|
|
114
|
+
if (!lock) return console.log("Pulse tick is already running.");
|
|
115
|
+
const { owner, release } = lock;
|
|
116
|
+
const expiredClaims = store.expiredClaims();
|
|
117
|
+
if (expiredClaims) console.error(`Pulse tick found ${expiredClaims} expired active claim(s). Run 'pi pulse recover' only after confirming their workers are gone.`);
|
|
118
|
+
let stopped = false, wake: (() => void) | undefined;
|
|
119
|
+
const active = new Set<Promise<void>>();
|
|
120
|
+
const launch = (pulse: Awaited<ReturnType<PulseStore["claimDue"]>>[number]) => {
|
|
121
|
+
let task: Promise<void>;
|
|
122
|
+
task = executePulse(store, pulse, owner).catch((error) => console.error(`Unexpected failure in Pulse '${pulse.pulse.name}': ${error instanceof Error ? error.message : String(error)}`)).finally(() => active.delete(task));
|
|
123
|
+
active.add(task);
|
|
124
|
+
};
|
|
125
|
+
const close = () => { stopped = true; wake?.(); };
|
|
126
|
+
process.once("SIGTERM", close); process.once("SIGINT", close);
|
|
127
|
+
try {
|
|
128
|
+
while (!stopped) {
|
|
129
|
+
if (!store.claimTickLease(owner)) { console.error("Pulse tick lost its SQLite lease; stopping."); break; }
|
|
130
|
+
// Claiming is atomic and exclusive per Pulse. Workers are intentionally
|
|
131
|
+
// launched independently so unrelated scheduled work can overlap.
|
|
132
|
+
for (const pulse of store.claimDue(undefined, owner)) { if (stopped) break; launch(pulse); }
|
|
133
|
+
await new Promise<void>((resolve) => { wake = resolve; setTimeout(resolve, TICK_INTERVAL_MS); }); wake = undefined;
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
// Do not release the scheduler lease while this tick still owns work.
|
|
137
|
+
await Promise.allSettled([...active]);
|
|
138
|
+
await release();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function handlePulseCli(args: string[], selectedProfile?: string): Promise<boolean> { if (args[0] !== "pulse") return false; const profileIndex = args.indexOf("--profile"); const profile = selectedProfile ?? (profileIndex >= 0 ? args[profileIndex + 1] : undefined); if (args[1] === "tick" && process.env.PI_PULSE_TICK === "1") { await tick(); return true; } switch (args[1]) { case "start": await start(); break; case "stop": await stop(); break; case "restart": await restart(); break; case "status": await status(); break; case "list": table(profile); break; case "recover": { const recovered = new PulseStore(dbPath()).recoverExpiredClaims(profile); console.log(`Recovered ${recovered} expired Pulse claim(s).`); break; } case "enable": case "disable": { const name = args[2]; if (!name) throw new Error("Usage: pi pulse enable|disable <name>"); new PulseStore(dbPath()).setEnabled(name, args[1] === "enable", profile); console.log(`Pulse '${name}' ${args[1]}d.`); break; } default: console.error("Usage: pi pulse start | stop | restart | status | list | recover | enable <name> | disable <name>"); process.exitCode = 1; } return true; }
|
|
29
142
|
export default async function (pi: ExtensionAPI) {
|
|
30
143
|
if (await handlePulseCli(process.argv.slice(2))) process.exit();
|
|
31
144
|
pi.on("before_agent_start", async (event) => ({ systemPrompt: `${event.systemPrompt}\n\nWhen the user asks to schedule, automate, remind, run future work, or manage an existing schedule, use the schedule tool. Do not ask for a thread or session ID: creation binds the schedule to this conversation automatically.` }));
|
|
32
145
|
pi.registerTool({
|
|
33
146
|
name: "schedule",
|
|
34
147
|
label: "Manage schedule",
|
|
35
|
-
description: "Create, list, edit, enable, disable, or delete cron jobs, heartbeats, reminders, and future schedules. Use this whenever the user asks to schedule or manage scheduled work.",
|
|
148
|
+
description: "Create, list, edit, enable, disable, or delete cron jobs, heartbeats, reminders, and future schedules. Use this whenever the user asks to schedule or manage scheduled work. When the user asks to notify or deliver the result through an Application channel such as WhatsApp, set insertIntoApiSession=true; this only records the result in active Application sessions, while sending remains the Pulse prompt/Skill's responsibility.",
|
|
36
149
|
parameters: Type.Object({
|
|
37
150
|
action: Type.Union([Type.Literal("create"), Type.Literal("list"), Type.Literal("update"), Type.Literal("enable"), Type.Literal("disable"), Type.Literal("delete")]),
|
|
38
151
|
name: Type.Optional(Type.String({ description: "Schedule name. Required except when listing." })),
|
|
39
152
|
description: Type.Optional(Type.String({ description: "Human-readable purpose, required for create." })),
|
|
40
153
|
schedule: Type.Optional(Type.String({ description: "Five-field UTC cron expression or @once:<ISO-8601>. Required for create." })),
|
|
41
154
|
prompt: Type.Optional(Type.String({ description: "Markdown instructions, required for create." })),
|
|
155
|
+
insertIntoApiSession: Type.Optional(Type.Boolean({ description: "Set true when the Pulse result should also be inserted into every active Application session for this profile. This records context only; it does not send through an outbound handler." })),
|
|
42
156
|
}),
|
|
43
157
|
async execute(_id, params, _signal, _update, ctx) {
|
|
44
158
|
try {
|
|
@@ -48,13 +162,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
48
162
|
if (params.action === "create") {
|
|
49
163
|
if (!params.description || !params.schedule || !params.prompt) throw new Error("Create requires name, description, schedule, and prompt.");
|
|
50
164
|
const threadSessionId = ctx.sessionManager.getSessionId(); if (!threadSessionId) throw new Error("A persistent conversation session is required to create a schedule.");
|
|
51
|
-
const pulse = store.create({ name: params.name, description: params.description, schedule: params.schedule, prompt: params.prompt, profile, thread_session_id: threadSessionId }); await start();
|
|
165
|
+
const pulse = store.create({ name: params.name, description: params.description, schedule: params.schedule, prompt: params.prompt, insertIntoApiSession: params.insertIntoApiSession === true, profile, thread_session_id: threadSessionId }); await start();
|
|
52
166
|
return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
|
|
53
167
|
}
|
|
54
168
|
if (params.action === "enable" || params.action === "disable") { const pulse = store.setEnabled(params.name, params.action === "enable", profile); return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse }; }
|
|
55
169
|
if (params.action === "delete") { store.delete(params.name, profile); return { content: [{ type: "text", text: `Schedule '${params.name}' deleted.` }], details: {} }; }
|
|
56
170
|
const current = store.get(params.name, profile); if (!current) throw new Error("Schedule not found.");
|
|
57
|
-
const pulse = store.update(params.name, profile, { description: params.description ?? current.description, schedule: params.schedule ?? current.schedule, prompt: params.prompt ?? current.prompt, thread_session_id: current.thread_session_id });
|
|
171
|
+
const pulse = store.update(params.name, profile, { description: params.description ?? current.description, schedule: params.schedule ?? current.schedule, prompt: params.prompt ?? current.prompt, thread_session_id: current.thread_session_id, insertIntoApiSession: params.insertIntoApiSession ?? current.insertIntoApiSession });
|
|
58
172
|
return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
|
|
59
173
|
} catch (error) { return { content: [{ type: "text", text: `Unable to manage schedule: ${error instanceof Error ? error.message : String(error)}` }], details: {}, isError: true }; }
|
|
60
174
|
},
|
|
@@ -4,9 +4,10 @@ import { mkdirSync } from "node:fs";
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
|
|
6
6
|
export type PulseType = "cron" | "heartbeat";
|
|
7
|
-
export type Pulse = { id: string; name: string; description: string; type: PulseType; schedule: string; prompt: string; thread_session_id: string; result: string | null; profile: string; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
7
|
+
export type Pulse = { id: string; name: string; description: string; type: PulseType; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; profile: string; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
8
8
|
export type PulseHistory = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
9
|
-
export type PulseRun = { id: string; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
9
|
+
export type PulseRun = { id: string; sessionId: string | null; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
10
|
+
export type ClaimedPulse = { pulse: Pulse; runId: string };
|
|
10
11
|
type Row = Record<string, unknown>;
|
|
11
12
|
const iso = (date = new Date()) => date.toISOString();
|
|
12
13
|
|
|
@@ -58,29 +59,47 @@ export class PulseStore {
|
|
|
58
59
|
private db: DatabaseSync;
|
|
59
60
|
constructor(path: string) {
|
|
60
61
|
mkdirSync(dirname(path), { recursive: true }); this.db = new DatabaseSync(path); this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
|
|
61
|
-
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, result TEXT);
|
|
62
|
-
CREATE TABLE IF NOT EXISTS pulse_control (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, profile TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT, last_run_at TEXT, updated_at TEXT NOT NULL);
|
|
62
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, insert_into_api_session INTEGER NOT NULL DEFAULT 0, result TEXT);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS pulse_control (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, profile TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT, last_run_at TEXT, claimed_at TEXT, active_run_id TEXT, claim_owner TEXT, lease_expires_at TEXT, updated_at TEXT NOT NULL);
|
|
63
64
|
CREATE TABLE IF NOT EXISTS pulse_state (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, handoff TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL);
|
|
64
|
-
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, session_id TEXT, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS pulse_tick_lease (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
65
67
|
CREATE INDEX IF NOT EXISTS pulse_runs_pulse_started ON pulse_runs(pulse_id, started_at DESC);`);
|
|
66
68
|
// Migrate databases created before the denormalized latest result column.
|
|
67
69
|
const columns = this.db.prepare("PRAGMA table_info(pulses)").all() as Row[];
|
|
68
70
|
if (!columns.some((column) => column.name === "result")) this.db.exec("ALTER TABLE pulses ADD COLUMN result TEXT");
|
|
71
|
+
if (!columns.some((column) => column.name === "insert_into_api_session")) this.db.exec("ALTER TABLE pulses ADD COLUMN insert_into_api_session INTEGER NOT NULL DEFAULT 0");
|
|
72
|
+
const runColumns = this.db.prepare("PRAGMA table_info(pulse_runs)").all() as Row[];
|
|
73
|
+
if (!runColumns.some((column) => column.name === "session_id")) this.db.exec("ALTER TABLE pulse_runs ADD COLUMN session_id TEXT");
|
|
74
|
+
const controlColumns = this.db.prepare("PRAGMA table_info(pulse_control)").all() as Row[];
|
|
75
|
+
for (const column of ["claimed_at", "active_run_id", "claim_owner", "lease_expires_at"]) if (!controlColumns.some((item) => item.name === column)) this.db.exec(`ALTER TABLE pulse_control ADD COLUMN ${column} TEXT`);
|
|
76
|
+
// Older ticks could create overlapping running rows. Resolve them before
|
|
77
|
+
// installing the database-level invariant that permits only one.
|
|
78
|
+
const duplicates = this.db.prepare("SELECT pulse_id FROM pulse_runs WHERE status='running' GROUP BY pulse_id HAVING COUNT(*) > 1").all() as Row[];
|
|
79
|
+
for (const duplicate of duplicates) {
|
|
80
|
+
const rows = this.db.prepare("SELECT id FROM pulse_runs WHERE pulse_id=? AND status='running' ORDER BY started_at DESC, id DESC").all(duplicate.pulse_id) as Row[];
|
|
81
|
+
for (const row of rows.slice(1)) this.db.prepare("UPDATE pulse_runs SET status='error',finished_at=?,error=? WHERE id=?").run(iso(), "Superseded while enforcing the single active Pulse run invariant.", row.id);
|
|
82
|
+
}
|
|
83
|
+
// Adopt a pre-migration active run so a legacy tick cannot be joined by a
|
|
84
|
+
// new scheduler until its bounded lease expires.
|
|
85
|
+
const active = this.db.prepare("SELECT pulse_id,id,started_at FROM pulse_runs WHERE status='running'").all() as Row[];
|
|
86
|
+
for (const run of active) this.db.prepare("UPDATE pulse_control SET active_run_id=COALESCE(active_run_id,?),claim_owner=COALESCE(claim_owner,'legacy'),claimed_at=COALESCE(claimed_at,?),lease_expires_at=COALESCE(lease_expires_at,?),next_run_at=NULL,updated_at=? WHERE pulse_id=?").run(run.id, run.started_at, new Date(Date.now() + 15 * 60_000).toISOString(), iso(), run.pulse_id);
|
|
87
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS pulse_runs_one_active ON pulse_runs(pulse_id) WHERE status='running'; CREATE INDEX IF NOT EXISTS pulse_control_due ON pulse_control(enabled, active_run_id, next_run_at);");
|
|
69
88
|
this.db.exec("UPDATE pulses SET result=(SELECT response FROM pulse_runs WHERE pulse_id=pulses.id AND status='success' ORDER BY finished_at DESC LIMIT 1) WHERE result IS NULL");
|
|
70
89
|
// Keep records created before the schedule-based classifier in sync.
|
|
71
90
|
for (const row of this.db.prepare("SELECT id, schedule, type FROM pulses").all() as Row[]) { const type = pulseType(String(row.schedule)); if (row.type !== type) this.db.prepare("UPDATE pulses SET type=? WHERE id=?").run(type, row.id); }
|
|
72
91
|
}
|
|
73
|
-
private pulse(row: Row): Pulse { return { id: String(row.id), name: String(row.name), description: String(row.description), type: row.type as PulseType, schedule: String(row.schedule), prompt: String(row.prompt), thread_session_id: String(row.thread_session_id), result: row.result == null ? null : String(row.result), profile: String(row.profile), enabled: Boolean(row.enabled), nextRunAt: row.next_run_at ? String(row.next_run_at) : null, lastRunAt: row.last_run_at ? String(row.last_run_at) : null }; }
|
|
92
|
+
private pulse(row: Row): Pulse { return { id: String(row.id), name: String(row.name), description: String(row.description), type: row.type as PulseType, schedule: String(row.schedule), prompt: String(row.prompt), thread_session_id: String(row.thread_session_id), insertIntoApiSession: Boolean(row.insert_into_api_session), result: row.result == null ? null : String(row.result), profile: String(row.profile), enabled: Boolean(row.enabled), nextRunAt: row.next_run_at ? String(row.next_run_at) : null, lastRunAt: row.last_run_at ? String(row.last_run_at) : null }; }
|
|
74
93
|
list(profile?: string): Pulse[] { const query = `SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id${profile ? " WHERE c.profile=?" : ""} ORDER BY p.name`; return this.db.prepare(query).all(...(profile ? [profile] : [])) .map((row) => this.pulse(row as Row)); }
|
|
75
94
|
get(name: string, profile?: string): Pulse | undefined { return this.list(profile).find((item) => item.name === name); }
|
|
76
95
|
history(profile: string): PulseHistory[] { return this.db.prepare(`SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at, r.started_at AS startedAt, r.finished_at AS finishedAt, r.status, r.error FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id JOIN pulse_runs r ON r.id=(SELECT id FROM pulse_runs WHERE pulse_id=p.id AND finished_at IS NOT NULL ORDER BY started_at DESC LIMIT 1) WHERE c.profile=? AND p.schedule LIKE '@once:%' ORDER BY r.started_at DESC`).all(profile).map((row) => { const record = row as Row; return { ...this.pulse(record), startedAt: String(record.startedAt), finishedAt: record.finishedAt == null ? null : String(record.finishedAt), status: String(record.status), error: record.error == null ? null : String(record.error) }; }); }
|
|
77
|
-
runs(profile: string, name: string, start: string, end: string, limit = 100): PulseRun[] { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); return this.db.prepare("SELECT id, started_at, finished_at, status, response, error FROM pulse_runs WHERE pulse_id=? AND started_at>=? AND started_at<=? ORDER BY started_at DESC LIMIT ?").all(pulse.id, start, end, limit).map((row) => { const item = row as Row; return { id: String(item.id), startedAt: String(item.started_at), finishedAt: item.finished_at == null ? null : String(item.finished_at), status: item.status as PulseRun["status"], response: item.response == null ? null : String(item.response), error: item.error == null ? null : String(item.error) }; }); }
|
|
78
|
-
create(input: Omit<Pulse, "id" | "enabled" | "nextRunAt" | "lastRunAt" | "type" | "result">): Pulse {
|
|
96
|
+
runs(profile: string, name: string, start: string, end: string, limit = 100): PulseRun[] { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); return this.db.prepare("SELECT id, session_id, started_at, finished_at, status, response, error FROM pulse_runs WHERE pulse_id=? AND started_at>=? AND started_at<=? ORDER BY started_at DESC LIMIT ?").all(pulse.id, start, end, limit).map((row) => { const item = row as Row; return { id: String(item.id), sessionId: item.session_id == null ? null : String(item.session_id), startedAt: String(item.started_at), finishedAt: item.finished_at == null ? null : String(item.finished_at), status: item.status as PulseRun["status"], response: item.response == null ? null : String(item.response), error: item.error == null ? null : String(item.error) }; }); }
|
|
97
|
+
create(input: Omit<Pulse, "id" | "enabled" | "nextRunAt" | "lastRunAt" | "type" | "result" | "insertIntoApiSession"> & { insertIntoApiSession?: boolean }): Pulse {
|
|
79
98
|
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(input.name)) throw new Error("Invalid pulse name.");
|
|
80
99
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|
|
81
100
|
const type = pulseType(input.schedule), id = randomUUID(), next = nextRun(input.schedule)?.toISOString() ?? null;
|
|
82
|
-
this.db.prepare("INSERT INTO pulses (id, name, description, type, schedule, prompt, thread_session_id, result) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)").run(id, input.name, input.description, type, input.schedule, input.prompt, input.thread_session_id);
|
|
83
|
-
this.db.prepare("INSERT INTO pulse_control VALUES (?, ?, 1, ?, NULL, ?)").run(id, input.profile, next, iso());
|
|
101
|
+
this.db.prepare("INSERT INTO pulses (id, name, description, type, schedule, prompt, thread_session_id, insert_into_api_session, result) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)").run(id, input.name, input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession ? 1 : 0);
|
|
102
|
+
this.db.prepare("INSERT INTO pulse_control (pulse_id, profile, enabled, next_run_at, last_run_at, claimed_at, updated_at) VALUES (?, ?, 1, ?, NULL, NULL, ?)").run(id, input.profile, next, iso());
|
|
84
103
|
if (type === "heartbeat") this.db.prepare("INSERT INTO pulse_state VALUES (?, '', ?)").run(id, iso());
|
|
85
104
|
return this.get(input.name, input.profile)!;
|
|
86
105
|
}
|
|
@@ -90,19 +109,92 @@ export class PulseStore {
|
|
|
90
109
|
this.db.prepare("UPDATE pulse_control SET enabled=?, next_run_at=?, updated_at=? WHERE pulse_id=?").run(enabled ? 1 : 0, next, iso(), pulse.id);
|
|
91
110
|
return this.get(name, profile)!;
|
|
92
111
|
}
|
|
93
|
-
update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id">): Pulse {
|
|
112
|
+
update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id" | "insertIntoApiSession">): Pulse {
|
|
94
113
|
const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
|
|
95
114
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|
|
96
115
|
const type = pulseType(input.schedule);
|
|
97
|
-
this.db.prepare("UPDATE pulses SET description=?, type=?, schedule=?, prompt=?, thread_session_id=? WHERE id=?").run(input.description, type, input.schedule, input.prompt, input.thread_session_id, pulse.id);
|
|
116
|
+
this.db.prepare("UPDATE pulses SET description=?, type=?, schedule=?, prompt=?, thread_session_id=?, insert_into_api_session=? WHERE id=?").run(input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession ? 1 : 0, pulse.id);
|
|
98
117
|
this.db.prepare("UPDATE pulse_control SET next_run_at=?, updated_at=? WHERE pulse_id=?").run(pulse.enabled ? nextRun(input.schedule)?.toISOString() ?? null : null, iso(), pulse.id);
|
|
99
118
|
return this.get(name, profile)!;
|
|
100
119
|
}
|
|
101
120
|
delete(name: string, profile?: string): void { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); this.db.prepare("DELETE FROM pulses WHERE id=?").run(pulse.id); }
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
deleteProfile(profile: string): void { this.db.prepare("DELETE FROM pulses WHERE id IN (SELECT pulse_id FROM pulse_control WHERE profile=?)").run(profile); }
|
|
122
|
+
due(now = iso()): Pulse[] { return this.db.prepare("SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id WHERE c.enabled=1 AND c.active_run_id IS NULL AND c.next_run_at IS NOT NULL AND c.next_run_at<=? ORDER BY c.next_run_at").all(now).map((row) => this.pulse(row as Row)); }
|
|
123
|
+
claimDue(now = iso(), owner = "unknown", leaseMs = 15 * 60_000): ClaimedPulse[] {
|
|
124
|
+
const claimed: ClaimedPulse[] = [], expiresAt = new Date(new Date(now).getTime() + leaseMs).toISOString();
|
|
125
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
126
|
+
try {
|
|
127
|
+
for (const pulse of this.due(now)) {
|
|
128
|
+
const runId = randomUUID();
|
|
129
|
+
const result = this.db.prepare("UPDATE pulse_control SET claimed_at=?,active_run_id=?,claim_owner=?,lease_expires_at=?,next_run_at=NULL,updated_at=? WHERE pulse_id=? AND enabled=1 AND active_run_id IS NULL AND next_run_at IS NOT NULL AND next_run_at<=?").run(now, runId, owner, expiresAt, now, pulse.id, now);
|
|
130
|
+
if (!result.changes) continue;
|
|
131
|
+
this.db.prepare("INSERT INTO pulse_runs (id, pulse_id, session_id, started_at, finished_at, status, response, error) VALUES (?, ?, NULL, ?, NULL, 'running', NULL, NULL)").run(runId, pulse.id, now);
|
|
132
|
+
claimed.push({ pulse, runId });
|
|
133
|
+
}
|
|
134
|
+
this.db.exec("COMMIT");
|
|
135
|
+
return claimed;
|
|
136
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
137
|
+
}
|
|
138
|
+
setRunSession(runId: string, sessionId: string): void { this.db.prepare("UPDATE pulse_runs SET session_id=? WHERE id=? AND status='running'").run(sessionId, runId); }
|
|
139
|
+
renewClaim(pulseId: string, runId: string, owner: string, leaseMs = 15 * 60_000): boolean { return Boolean(this.db.prepare("UPDATE pulse_control SET lease_expires_at=?,updated_at=? WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").run(new Date(Date.now() + leaseMs).toISOString(), iso(), pulseId, runId, owner).changes); }
|
|
140
|
+
expiredClaims(profile?: string, now = iso()): number {
|
|
141
|
+
const filter = profile ? " AND c.profile=?" : "";
|
|
142
|
+
return Number((this.db.prepare(`SELECT COUNT(*) AS count FROM pulse_control c WHERE c.active_run_id IS NOT NULL AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at<=?${filter}`).get(now, ...(profile ? [profile] : [])) as Row).count);
|
|
143
|
+
}
|
|
144
|
+
recoverExpiredClaims(profile?: string, now = iso()): number {
|
|
145
|
+
// Recovery is deliberately operator-triggered. A lease expiring alone does
|
|
146
|
+
// not prove that a child process is dead, so automatic recovery could run
|
|
147
|
+
// the same work twice. An operator can use this after confirming the old
|
|
148
|
+
// scheduler/worker is gone (for example, after a container restart).
|
|
149
|
+
const filter = profile ? " AND c.profile=?" : "";
|
|
150
|
+
const rows = this.db.prepare(`SELECT p.*, c.profile, c.active_run_id FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id WHERE c.active_run_id IS NOT NULL AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at<=?${filter}`).all(now, ...(profile ? [profile] : [])) as Row[];
|
|
151
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
152
|
+
try {
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
const pulse = this.pulse(row), runId = String(row.active_run_id), retry = nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null;
|
|
155
|
+
this.db.prepare("UPDATE pulse_runs SET finished_at=?,status='error',error=? WHERE id=? AND status='running'").run(now, "Recovered expired Pulse claim by operator.", runId);
|
|
156
|
+
this.db.prepare("UPDATE pulse_control SET next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=?").run(retry, now, pulse.id, runId);
|
|
157
|
+
}
|
|
158
|
+
this.db.exec("COMMIT"); return rows.length;
|
|
159
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
160
|
+
}
|
|
161
|
+
complete(pulse: Pulse, runId: string, response: string, handoff?: string, owner = "unknown"): void {
|
|
162
|
+
const now = iso(), next = nextRun(pulse.schedule)?.toISOString() ?? null;
|
|
163
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
164
|
+
try {
|
|
165
|
+
const owns = this.db.prepare("SELECT 1 FROM pulse_control WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").get(pulse.id, runId, owner);
|
|
166
|
+
if (owns) {
|
|
167
|
+
this.db.prepare("UPDATE pulse_runs SET finished_at=?,status='success',response=? WHERE id=? AND status='running'").run(now, response, runId);
|
|
168
|
+
this.db.prepare("UPDATE pulse_control SET last_run_at=?,next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").run(now, next, now, pulse.id, runId, owner);
|
|
169
|
+
this.db.prepare("UPDATE pulses SET result=? WHERE id=?").run(response, pulse.id);
|
|
170
|
+
if (pulse.type === "heartbeat" && handoff !== undefined) this.db.prepare("UPDATE pulse_state SET handoff=?,updated_at=? WHERE pulse_id=?").run(handoff, now, pulse.id);
|
|
171
|
+
}
|
|
172
|
+
this.db.exec("COMMIT");
|
|
173
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
174
|
+
}
|
|
175
|
+
fail(pulse: Pulse, runId: string, error: string, owner = "unknown"): void {
|
|
176
|
+
const now = iso(), retry = nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null;
|
|
177
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
178
|
+
try {
|
|
179
|
+
const owns = this.db.prepare("SELECT 1 FROM pulse_control WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").get(pulse.id, runId, owner);
|
|
180
|
+
if (owns) {
|
|
181
|
+
this.db.prepare("UPDATE pulse_runs SET finished_at=?,status='error',error=? WHERE id=? AND status='running'").run(now, error, runId);
|
|
182
|
+
this.db.prepare("UPDATE pulse_control SET next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").run(retry, now, pulse.id, runId, owner);
|
|
183
|
+
}
|
|
184
|
+
this.db.exec("COMMIT");
|
|
185
|
+
} catch (cause) { this.db.exec("ROLLBACK"); throw cause; }
|
|
186
|
+
}
|
|
187
|
+
claimTickLease(owner: string, leaseMs = 90_000): boolean {
|
|
188
|
+
const now = iso(), expiresAt = new Date(Date.now() + leaseMs).toISOString();
|
|
189
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
190
|
+
try {
|
|
191
|
+
const current = this.db.prepare("SELECT owner,expires_at FROM pulse_tick_lease WHERE name='scheduler'").get() as Row | undefined;
|
|
192
|
+
if (current && String(current.owner) !== owner && String(current.expires_at) > now) { this.db.exec("COMMIT"); return false; }
|
|
193
|
+
this.db.prepare("INSERT INTO pulse_tick_lease(name,owner,expires_at,updated_at) VALUES ('scheduler',?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,expires_at=excluded.expires_at,updated_at=excluded.updated_at").run(owner, expiresAt, now);
|
|
194
|
+
this.db.exec("COMMIT"); return true;
|
|
195
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
196
|
+
}
|
|
197
|
+
releaseTickLease(owner: string): void { this.db.prepare("DELETE FROM pulse_tick_lease WHERE name='scheduler' AND owner=?").run(owner); }
|
|
106
198
|
handoff(id: string): string { return String((this.db.prepare("SELECT handoff FROM pulse_state WHERE pulse_id=?").get(id) as Row | undefined)?.handoff ?? ""); }
|
|
107
199
|
retarget(profile: string, from: string, to: string): void { this.db.prepare("UPDATE pulses SET thread_session_id=? WHERE id IN (SELECT pulse_id FROM pulse_control WHERE profile=?) AND thread_session_id=?").run(to, profile, from); }
|
|
108
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-feats",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "A modular Pi package for profiles, sandboxing, remote access, applications, guardrails, skills, session management, observability, and web-based operations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -92,9 +92,11 @@
|
|
|
92
92
|
"./extensions/cli-resources.ts",
|
|
93
93
|
"./extensions/composio",
|
|
94
94
|
"./extensions/context-memory",
|
|
95
|
+
"./extensions/conversation-search",
|
|
95
96
|
"./extensions/guardrails",
|
|
96
97
|
"./extensions/pi-console-webui",
|
|
97
98
|
"./extensions/profiles.ts",
|
|
99
|
+
"./extensions/profile-ssh.ts",
|
|
98
100
|
"./extensions/pulse",
|
|
99
101
|
"./extensions/sequential-workflow.ts",
|
|
100
102
|
"./extensions/skill-sources"
|