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.
Files changed (29) hide show
  1. package/README.md +43 -5
  2. package/extensions/api-server/application-log-store.ts +4 -2
  3. package/extensions/api-server/application-runtime.ts +16 -6
  4. package/extensions/api-server/application-store.ts +21 -8
  5. package/extensions/api-server/profile-store.ts +33 -10
  6. package/extensions/api-server/server.ts +43 -4
  7. package/extensions/cli-resources.ts +20 -20
  8. package/extensions/conversation-search/index.ts +42 -0
  9. package/extensions/lib/builtin-tools.ts +24 -0
  10. package/extensions/lib/conversation-search.ts +33 -0
  11. package/extensions/lib/profile-sandbox.ts +3 -0
  12. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  13. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  14. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  15. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  16. package/extensions/pi-console-webui/components/console-page.tsx +3 -0
  17. package/extensions/pi-console-webui/components/console.tsx +9 -5
  18. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  19. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  20. package/extensions/pi-console-webui/components/pulse-resources.tsx +5 -5
  21. package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
  22. package/extensions/pi-console-webui/lib/page-metadata.ts +3 -1
  23. package/extensions/pi-console-webui/next.config.js +1 -0
  24. package/extensions/profile-ssh.ts +274 -0
  25. package/extensions/profiles.ts +53 -17
  26. package/extensions/pulse/index.ts +128 -14
  27. package/extensions/pulse/store.ts +108 -16
  28. package/package.json +3 -1
  29. package/skills/profile-ssh/SKILL.md +65 -0
@@ -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 sharedResources(root: string, policy: ProfilePolicy = {}, localSkillsDir?: string) {
103
- // Extensions are loaded through Pi package settings. Do not materialize paths
104
- // from the native extensions directory: a Git package lives outside it.
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 ? (!policy.enabledSkills || policy.enabledSkills.includes("*") ? [join(root, "skills")] : policy.enabledSkills.map((name) => join(root, "skills", name)).filter(existsSync)) : []),
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
- async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<string[]> {
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
- if (!enabled || enabled.includes("*") || enabled.includes(name)) sources.add(path);
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
- const modelsPath = join(root, "models.json");
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
- pi.setActiveTools(pi.getAllTools().filter((tool) => allowed.has(tool.name)).map((tool) => tool.name));
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { existsSync, openSync } from "node:fs";
4
- import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
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"); const statePath = () => join(root(), "pulse-tick.state.json"); const logPath = () => join(root(), "pulse-tick.log");
12
- type State = { pid: number; startedAt: string };
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 = join(root(), profile === "default" ? "sessions" : join("profiles", profile, "sessions"));
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}).`); await unlink(statePath()).catch(() => {}); 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(); await write(statePath(), { pid: child.pid!, startedAt: new Date().toISOString() }); console.log(`Pulse tick started (PID ${child.pid}).`); }
25
- async function stop() { const current = await json<State>(statePath()); if (!current || !alive(current.pid)) { await unlink(statePath()).catch(() => {}); return console.log("Pulse tick is not running."); } try { process.kill(-current.pid, "SIGTERM"); } catch { process.kill(current.pid, "SIGTERM"); } await unlink(statePath()).catch(() => {}); console.log("Pulse tick stopped."); }
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 tick() { const store = new PulseStore(dbPath()); const execute = async () => { for (const pulse of store.due()) { const run = store.begin(pulse); try { const handoff = pulse.type === "heartbeat" ? store.handoff(pulse.id) : ""; const message = `[Pulse: ${pulse.name}]\n${pulse.prompt}${handoff ? `\n\nPrevious heartbeat state (private):\n${handoff}` : ""}`; const target = pulse.thread_session_id ? await sessionFile(pulse.profile, pulse.thread_session_id) : undefined; if (pulse.thread_session_id && !target) throw new Error(`Thread session '${pulse.thread_session_id}' was not found for profile '${pulse.profile}'.`); const result = await new Promise<string>((resolve, reject) => { const child = spawn(process.execPath, [process.argv[1], "profile", pulse.profile, ...(target ? ["--session", target.file] : ["--no-session"]), "--print", message], { cwd: target?.cwd ?? process.cwd(), env: { ...process.env, PI_PULSE_TICK: "1" }, stdio: ["ignore", "pipe", "pipe"] }); let output = "", error = ""; child.stdout.on("data", (data) => output += String(data)); child.stderr.on("data", (data) => error += String(data)); child.once("exit", (code) => code === 0 ? resolve(output.trim()) : reject(new Error(error.trim() || `Pi exited with ${code}`))); child.once("error", reject); }); const state = pulse.type === "heartbeat" ? result.slice(-8000) : undefined; store.complete(pulse, run, result, state); } catch (error) { store.fail(pulse, run, error instanceof Error ? error.message : String(error)); } } }; await execute(); const timer = setInterval(() => { void execute(); }, 15_000); await new Promise<void>((resolve) => { const close = () => { clearInterval(timer); resolve(); }; process.once("SIGTERM", close); process.once("SIGINT", close); }); }
28
- 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 "status": await status(); break; case "list": table(profile); 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 | status | list | enable <name> | disable <name>"); process.exitCode = 1; } return true; }
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
- 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.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)); }
103
- begin(pulse: Pulse): string { const id = randomUUID(); this.db.prepare("INSERT INTO pulse_runs VALUES (?, ?, ?, NULL, 'running', NULL, NULL)").run(id, pulse.id, iso()); return id; }
104
- complete(pulse: Pulse, runId: string, response: string, handoff?: string): void { const next = nextRun(pulse.schedule)?.toISOString() ?? null; this.db.prepare("UPDATE pulse_runs SET finished_at=?, status='success', response=? WHERE id=?").run(iso(), response, runId); this.db.prepare("UPDATE pulses SET result=? WHERE id=?").run(response, pulse.id); this.db.prepare("UPDATE pulse_control SET last_run_at=?, next_run_at=?, updated_at=? WHERE pulse_id=?").run(iso(), next, iso(), pulse.id); if (pulse.type === "heartbeat" && handoff !== undefined) this.db.prepare("UPDATE pulse_state SET handoff=?, updated_at=? WHERE pulse_id=?").run(handoff, iso(), pulse.id); }
105
- fail(pulse: Pulse, runId: string, error: string): void { this.db.prepare("UPDATE pulse_runs SET finished_at=?, status='error', error=? WHERE id=?").run(iso(), error, runId); this.db.prepare("UPDATE pulse_control SET next_run_at=?, updated_at=? WHERE pulse_id=?").run(nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null, iso(), pulse.id); }
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.4",
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"