pi-feats 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +508 -0
  3. package/extensions/README.md +27 -0
  4. package/extensions/api-server/PLAN.md +70 -0
  5. package/extensions/api-server/README.md +103 -0
  6. package/extensions/api-server/application-log-store.ts +21 -0
  7. package/extensions/api-server/application-runtime.ts +212 -0
  8. package/extensions/api-server/application-store.ts +30 -0
  9. package/extensions/api-server/index.ts +52 -0
  10. package/extensions/api-server/profile-store.ts +367 -0
  11. package/extensions/api-server/server.ts +863 -0
  12. package/extensions/cli-resources.ts +564 -0
  13. package/extensions/guardrails/index.ts +178 -0
  14. package/extensions/lib/application-handler-templates.ts +63 -0
  15. package/extensions/lib/profile-env.ts +61 -0
  16. package/extensions/lib/profile-sandbox.ts +197 -0
  17. package/extensions/lib/remote-hosts.ts +392 -0
  18. package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
  19. package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
  20. package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
  21. package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
  22. package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
  23. package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
  24. package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
  25. package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
  26. package/extensions/pi-console-webui/app/globals.css +41 -0
  27. package/extensions/pi-console-webui/app/icon.svg +1 -0
  28. package/extensions/pi-console-webui/app/layout.tsx +5 -0
  29. package/extensions/pi-console-webui/app/login/page.tsx +11 -0
  30. package/extensions/pi-console-webui/app/page.tsx +2 -0
  31. package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
  32. package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
  33. package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
  34. package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
  35. package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
  36. package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
  37. package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
  38. package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
  39. package/extensions/pi-console-webui/components/applications.tsx +15 -0
  40. package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
  41. package/extensions/pi-console-webui/components/console-page.tsx +23 -0
  42. package/extensions/pi-console-webui/components/console-state.tsx +30 -0
  43. package/extensions/pi-console-webui/components/console.tsx +115 -0
  44. package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
  45. package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
  46. package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
  47. package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
  48. package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
  49. package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
  50. package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
  51. package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
  52. package/extensions/pi-console-webui/components/toast.tsx +18 -0
  53. package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
  54. package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
  55. package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
  56. package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
  57. package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
  58. package/extensions/pi-console-webui/components.json +8 -0
  59. package/extensions/pi-console-webui/index.ts +33 -0
  60. package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
  61. package/extensions/pi-console-webui/lib/auth.ts +21 -0
  62. package/extensions/pi-console-webui/lib/config.ts +15 -0
  63. package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
  64. package/extensions/pi-console-webui/lib/utils.ts +3 -0
  65. package/extensions/pi-console-webui/next-env.d.ts +6 -0
  66. package/extensions/pi-console-webui/next.config.js +5 -0
  67. package/extensions/pi-console-webui/postcss.config.js +1 -0
  68. package/extensions/pi-console-webui/tailwind.config.ts +2 -0
  69. package/extensions/pi-console-webui/tsconfig.json +41 -0
  70. package/extensions/profiles.ts +439 -0
  71. package/extensions/pulse/index.ts +62 -0
  72. package/extensions/pulse/store.ts +105 -0
  73. package/extensions/sequential-workflow.ts +270 -0
  74. package/extensions/skill-sources/index.ts +4 -0
  75. package/extensions/skill-sources/store.ts +118 -0
  76. package/package.json +89 -0
  77. package/scripts/install-nono.sh +34 -0
@@ -0,0 +1,439 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, render, Text } from "ink";
3
+ import React from "react";
4
+ import { existsSync } from "node:fs";
5
+ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { basename, join, resolve } from "node:path";
8
+ import { spawn } from "node:child_process";
9
+ import { ensureNonoAvailable, ensureProfileSandbox, isSandboxEnabled, migrateLegacySandboxRuntime, sandboxedCommand } from "./lib/profile-sandbox.ts";
10
+ import { profileEnvironment } from "./lib/profile-env.ts";
11
+ import { handleRemoteCli, REMOTE_COMMAND_NAMES } from "./lib/remote-hosts.ts";
12
+
13
+ type ProfilePolicy = {
14
+ enabledTools?: string[];
15
+ enabledSkills?: string[];
16
+ enabledProfileSkills?: string[];
17
+ enabledExtensions?: string[];
18
+ skillSources?: { shared?: boolean; profile?: boolean };
19
+ };
20
+
21
+ type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy };
22
+
23
+ const defaultAgentDir = join(homedir(), ".pi", "agent");
24
+ const rootAgentDir = () => process.env.PI_PROFILE_ROOT ?? defaultAgentDir;
25
+ const profilesDir = () => join(rootAgentDir(), "profiles");
26
+ const profileDir = (name: string) => join(profilesDir(), name);
27
+ const validProfileName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name);
28
+ const reservedProfileNames = new Set([
29
+ "add", "create", "delete", "disable", "enable", "extensions", "guardrails", "list", "open", "packages", "profile", "pulse", "remove", "rename", "resume", "sessions", "skills", "tools", "validate",
30
+ ...REMOTE_COMMAND_NAMES,
31
+ ]);
32
+
33
+ function fail(message: string): never {
34
+ process.stderr.write(`Profile error: ${message}\n`);
35
+ process.exit(1);
36
+ }
37
+
38
+ type GuardrailRow = { name: string; stage: string; mode: string; order: number; file: string; enabled?: boolean };
39
+
40
+ async function renderGuardrailTable(guardrails: GuardrailRow[]): Promise<void> {
41
+ const columns: Array<[string, number]> = [["STATUS", 8], ["STAGE", 8], ["ORDER", 5], ["MODE", 9], ["NAME", 13], ["FILE", 18]];
42
+ const clip = (value: string, width: number) => value.length <= width ? value : `${value.slice(0, width - 1)}…`;
43
+ const cell = (value: string, width: number) => clip(value, width).padEnd(width);
44
+ const line = `┼${columns.map(([, width]) => "─".repeat(width + 2)).join("┼")}┼`;
45
+ const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐");
46
+ const bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
47
+ const row = (values: string[]) => `│ ${values.map((value, index) => cell(value, columns[index][1])).join(" │ ")} │`;
48
+ const entries = guardrails.length
49
+ ? guardrails.sort((a, b) => a.stage.localeCompare(b.stage) || a.order - b.order || a.name.localeCompare(b.name)).map((guardrail) => React.createElement(Text, { key: guardrail.name, color: guardrail.enabled === false ? "gray" : "white" }, row([guardrail.enabled === false ? "disabled" : "enabled", guardrail.stage, String(guardrail.order), guardrail.mode, guardrail.name, guardrail.file])))
50
+ : [React.createElement(Text, { key: "empty", color: "gray" }, row(["—", "—", "—", "—", "No guardrails configured", "—"]))];
51
+ const app = render(React.createElement(Box, { flexDirection: "column" },
52
+ React.createElement(Text, { color: "gray" }, top),
53
+ React.createElement(Text, { color: "cyan", bold: true }, row(columns.map(([name]) => name))),
54
+ React.createElement(Text, { color: "gray" }, line),
55
+ ...entries,
56
+ React.createElement(Text, { color: "gray" }, bottom),
57
+ ), { stdout: process.stdout, stdin: process.stdin, exitOnCtrlC: false, patchConsole: false });
58
+ await new Promise((resolveRender) => setTimeout(resolveRender, 25));
59
+ app.unmount();
60
+ }
61
+
62
+ async function handleGuardrailsCli(profile: string, args: string[]): Promise<void> {
63
+ const directory = profile === "default" ? rootAgentDir() : profileDir(profile);
64
+ const configPath = join(directory, "guardrails.json");
65
+ let config: { guardrails: GuardrailRow[] };
66
+ try { config = JSON.parse(await readFile(configPath, "utf8")); }
67
+ catch (error) { fail(`could not read ${configPath}: ${error instanceof Error ? error.message : String(error)}`); }
68
+ if (!Array.isArray(config.guardrails)) fail(`${configPath} must contain a guardrails array.`);
69
+ const action = args[1] ?? "list";
70
+ if (action === "list") return renderGuardrailTable(config.guardrails);
71
+ if (action === "validate") {
72
+ for (const guardrail of config.guardrails) {
73
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(guardrail.name) || !["input", "pre_tool", "post_tool", "output"].includes(guardrail.stage) || !["transform", "evaluate", "reflect"].includes(guardrail.mode) || !Number.isInteger(guardrail.order) || basename(guardrail.file) !== guardrail.file || !guardrail.file.endsWith(".md")) fail(`invalid guardrail '${guardrail.name}'.`);
74
+ if (!existsSync(join(rootAgentDir(), "guardrails", guardrail.file))) fail(`guardrail prompt does not exist: ${guardrail.file}`);
75
+ }
76
+ return writeStdout("Guardrails are valid.\n");
77
+ }
78
+ if (action !== "enable" && action !== "disable") fail("usage: pi guardrails list | enable <name> | disable <name> | validate");
79
+ const name = args[2];
80
+ const guardrail = config.guardrails.find((item) => item.name === name);
81
+ if (!guardrail) fail(`guardrail '${name ?? ""}' is not configured.`);
82
+ guardrail.enabled = action === "enable";
83
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
84
+ await writeStdout(`Guardrail '${guardrail.name}' ${action}d.\n`);
85
+ }
86
+
87
+ async function writeStdout(text: string) {
88
+ await new Promise<void>((resolveWrite, reject) => {
89
+ process.stdout.write(text, (error) => error ? reject(error) : resolveWrite());
90
+ });
91
+ }
92
+
93
+ async function readJson(path: string): Promise<ProfileSettings> {
94
+ try {
95
+ return JSON.parse(await readFile(path, "utf8")) as ProfileSettings;
96
+ } catch (error) {
97
+ throw new Error(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`);
98
+ }
99
+ }
100
+
101
+ function sharedResources(root: string, policy: ProfilePolicy = {}, localSkillsDir?: string) {
102
+ // Extensions are loaded through Pi package settings. Do not materialize paths
103
+ // from the native extensions directory: a Git package lives outside it.
104
+ return {
105
+ skills: [
106
+ ...(policy.skillSources?.profile === true && localSkillsDir ? (!policy.enabledProfileSkills || policy.enabledProfileSkills.includes("*") ? [localSkillsDir] : policy.enabledProfileSkills.map((name) => join(localSkillsDir, name)).filter(existsSync)) : []),
107
+ ...(policy.skillSources?.shared !== false ? (!policy.enabledSkills || policy.enabledSkills.includes("*") ? [join(root, "skills")] : policy.enabledSkills.map((name) => join(root, "skills", name)).filter(existsSync)) : []),
108
+ ],
109
+ prompts: [join(root, "prompts")],
110
+ themes: [join(root, "themes")],
111
+ };
112
+ }
113
+
114
+ function packageInstallPath(root: string, source: string): string | undefined {
115
+ if (source.startsWith("npm:")) {
116
+ const spec = source.slice(4);
117
+ const versionAt = spec.lastIndexOf("@");
118
+ const packageName = versionAt > (spec.startsWith("@") ? spec.indexOf("/") : -1) ? spec.slice(0, versionAt) : spec;
119
+ const path = join(root, "npm", "node_modules", packageName);
120
+ return existsSync(join(path, "package.json")) ? path : undefined;
121
+ }
122
+ if (!source.startsWith("git:") && !/^(?:https?|ssh|git):\/\//.test(source)) return undefined;
123
+ let remote = source.replace(/^git:/, "");
124
+ const refAt = remote.lastIndexOf("@");
125
+ if (refAt > remote.lastIndexOf("/")) remote = remote.slice(0, refAt);
126
+ remote = remote.replace(/^(?:https?|git):\/\//, "").replace(/^ssh:\/\/git@/, "").replace(/^git@/, "").replace(/^([^/:]+):/, "$1/").replace(/\.git$/, "");
127
+ const path = join(root, "git", remote);
128
+ return existsSync(join(path, "package.json")) ? path : undefined;
129
+ }
130
+
131
+ async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<string[]> {
132
+ const sources = new Set<string>();
133
+ const add = (source: unknown) => {
134
+ if (typeof source !== "string") return;
135
+ const packagePath = packageInstallPath(root, source);
136
+ const path = packagePath ?? (source.startsWith(".") ? resolve(root, source) : source);
137
+ if (existsSync(path)) sources.add(path);
138
+ };
139
+ if (Array.isArray(base.packages)) for (const entry of base.packages) add(typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry);
140
+ if (Array.isArray(base.extensions)) for (const entry of base.extensions) add(entry);
141
+ // Pi auto-discovers these for the default profile. Add them explicitly when
142
+ // re-executing a named profile, where that profile's extensions directory is
143
+ // intentionally empty.
144
+ try {
145
+ for (const entry of await readdir(join(root, "extensions"), { withFileTypes: true })) {
146
+ const path = join(root, "extensions", entry.name);
147
+ if (entry.isFile() && [".ts", ".js"].some((extension) => entry.name.endsWith(extension))) add(path);
148
+ else if (entry.isDirectory() && (existsSync(join(path, "index.ts")) || existsSync(join(path, "index.js")))) add(path);
149
+ }
150
+ } catch {}
151
+ return [...sources];
152
+ }
153
+
154
+ function profileSettings(root: string, base: ProfileSettings, localSkillsDir: string): ProfileSettings {
155
+ const { packages: _packages, extensions: _extensions, ...profileBase } = base;
156
+ return {
157
+ ...profileBase,
158
+ ...sharedResources(root, { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false } }, localSkillsDir),
159
+ defaultTools: ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"],
160
+ profile: {
161
+ enabledTools: ["*"],
162
+ enabledSkills: ["*"],
163
+ enabledProfileSkills: ["*"],
164
+ skillSources: { shared: true, profile: false },
165
+ },
166
+ };
167
+ }
168
+
169
+ type ProfileRow = { name: string; path: string };
170
+
171
+ function ProfileTable({ profiles }: { profiles: ProfileRow[] }) {
172
+ const terminalWidth = Math.max(60, process.stdout.columns ?? 80);
173
+ const nameWidth = Math.min(24, Math.max(12, Math.floor((terminalWidth - 7) * 0.28)));
174
+ const pathWidth = terminalWidth - nameWidth - 7;
175
+ const clip = (value: string, width: number) => value.length <= width ? value : `${value.slice(0, width - 1)}…`;
176
+ const cell = (value: string, width: number) => clip(value, width).padEnd(width);
177
+ const line = `┼${"─".repeat(nameWidth + 2)}┼${"─".repeat(pathWidth + 2)}┼`;
178
+ const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐");
179
+ const bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
180
+ const row = (name: string, path: string) => `│ ${cell(name, nameWidth)} │ ${cell(path, pathWidth)} │`;
181
+
182
+ return React.createElement(
183
+ Box,
184
+ { flexDirection: "column" },
185
+ React.createElement(Text, { color: "gray" }, top),
186
+ React.createElement(Text, { color: "cyan", bold: true }, row("PROFILE", "PATH")),
187
+ React.createElement(Text, { color: "gray" }, line),
188
+ ...profiles.map((profile) => React.createElement(Text, { color: "white", key: profile.name }, row(profile.name, profile.path))),
189
+ React.createElement(Text, { color: "gray" }, bottom),
190
+ );
191
+ }
192
+
193
+ async function listProfiles() {
194
+ const root = rootAgentDir();
195
+ const profiles: ProfileRow[] = [{ name: "default", path: root }];
196
+ if (existsSync(profilesDir())) {
197
+ for (const entry of await readdir(profilesDir(), { withFileTypes: true })) {
198
+ if (entry.isDirectory() && existsSync(join(profilesDir(), entry.name, "settings.json"))) {
199
+ profiles.push({ name: entry.name, path: profileDir(entry.name) });
200
+ }
201
+ }
202
+ }
203
+ const app = render(React.createElement(ProfileTable, { profiles }), {
204
+ stdout: process.stdout,
205
+ stdin: process.stdin,
206
+ exitOnCtrlC: false,
207
+ patchConsole: false,
208
+ });
209
+ await new Promise((resolveRender) => setTimeout(resolveRender, 25));
210
+ app.unmount();
211
+ }
212
+
213
+ async function removeProfileRuntimeArtifacts(destination: string) {
214
+ // These locations belonged to the former per-profile extension bootstrap.
215
+ // A profile is a workspace, never a package or extension installation root.
216
+ await Promise.all(["extensions", "git", "npm", "node_modules"].map((name) => rm(join(destination, name), { recursive: true, force: true })));
217
+ }
218
+
219
+ async function createProfile(name: string) {
220
+ if (!validProfileName(name)) fail("invalid name; use letters, numbers, hyphens, or underscores (max. 64 characters).");
221
+ if (name === "default") fail("default is the primary profile and cannot be created.");
222
+ if (reservedProfileNames.has(name.toLowerCase())) fail(`'${name}' is reserved as a Pi command and cannot be used as a profile name.`);
223
+ await ensureNonoAvailable();
224
+ const root = rootAgentDir();
225
+ const destination = profileDir(name);
226
+ if (existsSync(destination)) fail(`profile '${name}' already exists.`);
227
+
228
+ await mkdir(join(destination, "sessions"), { recursive: true });
229
+ const baseSettingsPath = join(root, "settings.json");
230
+ const base = existsSync(baseSettingsPath) ? await readJson(baseSettingsPath) : {};
231
+ await writeFile(join(destination, "settings.json"), `${JSON.stringify({ ...profileSettings(root, base, join(destination, "skills")), sandbox: true }, null, 2)}\n`);
232
+ await ensureProfileSandbox(destination, resolve(process.argv[1]));
233
+ await writeFile(join(destination, "SOUL.md"), "");
234
+ await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n");
235
+
236
+ const authPath = join(root, "auth.json");
237
+ if (existsSync(authPath)) await copyFile(authPath, join(destination, "auth.json"));
238
+
239
+ const modelsPath = join(root, "models.json");
240
+ if (existsSync(modelsPath)) {
241
+ await symlink(modelsPath, join(destination, "models.json"));
242
+ }
243
+
244
+ await writeStdout(`Profile '${name}' created at ${destination}\n`);
245
+ }
246
+
247
+ async function deleteProfile(name: string, force: boolean) {
248
+ if (!validProfileName(name) || name === "default") fail("default cannot be deleted.");
249
+ const destination = profileDir(name);
250
+ if (!existsSync(join(destination, "settings.json"))) fail(`profile '${name}' does not exist.`);
251
+ if (!force) fail("deletion requires --force: pi profile delete <name> --force");
252
+ await rm(destination, { recursive: true, force: false });
253
+ await writeStdout(`Profile '${name}' deleted.\n`);
254
+ }
255
+
256
+ async function handleProfileCommand(args: string[]) {
257
+ const action = args[1];
258
+ if (action === "list" && args.length === 2) return listProfiles();
259
+ if (action === "create" && args.length === 3) return createProfile(args[2]);
260
+ if (action === "delete" && (args.length === 3 || (args.length === 4 && args[3] === "--force"))) {
261
+ return deleteProfile(args[2], args[3] === "--force");
262
+ }
263
+ if (action === "resume" && args.length === 3) return reexecWithProfile(args[2], ["--resume"]);
264
+ if (action === "open" && args.length === 4) return reexecWithProfile(args[2], ["--session", args[3]]);
265
+ // Accept the natural profile-first forms too. This is particularly useful
266
+ // for remote operation, where a session ID is copied from `sessions list`.
267
+ if (action && !reservedProfileNames.has(action.toLowerCase())) {
268
+ if (args[2] === "resume" && args.length === 3) return reexecWithProfile(action, ["--resume"]);
269
+ if ((args[2] === "resume" || args[2] === "open") && args.length === 4) return reexecWithProfile(action, ["--session", args[3]]);
270
+ return reexecWithProfile(action, args.slice(2));
271
+ }
272
+ fail("usage: pi profile <name> [pi arguments] | pi profile list | pi profile create <name> | pi profile delete <name> --force | pi profile resume <name> | pi profile open <name> <session-id>");
273
+ }
274
+
275
+ // Pi exposes its complete argv to extensions. Re-executed profiles prepend
276
+ // runtime-only flags, so command dispatch must ignore those pairs instead of
277
+ // assuming the subcommand is always argv[0].
278
+ function commandArgs(raw: string[]): string[] {
279
+ const result: string[] = [];
280
+ for (let index = 0; index < raw.length; index += 1) {
281
+ const value = raw[index];
282
+ if (value === "--extension" || value === "--session-dir") { index += 1; continue; }
283
+ if (value.startsWith("--extension=") || value.startsWith("--session-dir=")) continue;
284
+ result.push(value);
285
+ }
286
+ return result;
287
+ }
288
+
289
+ function extractResumeCommand(args: string[]) {
290
+ if (args[0] === "resume" && args.length === 2) {
291
+ return { profile: "default", sessionId: args[1] };
292
+ }
293
+ const requestedProfile = extractProfile(args);
294
+ if (requestedProfile && requestedProfile.args[0] === "resume" && requestedProfile.args.length === 2) {
295
+ return { profile: requestedProfile.name, sessionId: requestedProfile.args[1] };
296
+ }
297
+ return undefined;
298
+ }
299
+
300
+ function extractProfile(args: string[]) {
301
+ for (let index = 0; index < args.length; index += 1) {
302
+ if (args[index] === "--profile") {
303
+ const name = args[index + 1];
304
+ if (!name) fail("--profile requires a name.");
305
+ return { name, args: [...args.slice(0, index), ...args.slice(index + 2)] };
306
+ }
307
+ if (args[index].startsWith("--profile=")) {
308
+ const name = args[index].slice("--profile=".length);
309
+ if (!name) fail("--profile requires a name.");
310
+ return { name, args: [...args.slice(0, index), ...args.slice(index + 1)] };
311
+ }
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ async function syncProfileResources(name: string) {
317
+ if (name === "default") return;
318
+ const settingsPath = join(profileDir(name), "settings.json");
319
+ const root = rootAgentDir();
320
+ const settings = await readJson(settingsPath);
321
+ // Packages and extensions belong to the default runtime. Profiles only own
322
+ // workspace state and resource policy; rootRuntimeSources() injects the
323
+ // shared runtime when this profile is launched.
324
+ delete settings.packages;
325
+ delete settings.extensions;
326
+ if (settings.profile) delete settings.profile.enabledExtensions;
327
+ Object.assign(settings, sharedResources(root, settings.profile, join(profileDir(name), "skills")));
328
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
329
+ await removeProfileRuntimeArtifacts(profileDir(name));
330
+ }
331
+
332
+ async function reexecWithProfile(name: string, args: string[]) {
333
+ if (name !== "default" && (!validProfileName(name) || !existsSync(join(profileDir(name), "settings.json")))) {
334
+ fail(`profile '${name}' does not exist.`);
335
+ }
336
+ const root = rootAgentDir();
337
+ const target = name === "default" ? root : profileDir(name);
338
+ if (name !== "default") await migrateLegacySandboxRuntime(target);
339
+ await syncProfileResources(name);
340
+ const baseSettingsPath = join(root, "settings.json");
341
+ const rootSettings = existsSync(baseSettingsPath) ? await readJson(baseSettingsPath) : {};
342
+ // The default runtime owns extensions and packages. Named profiles receive
343
+ // their already installed local paths, never npm:/git: specs. The default
344
+ // profile already loads these resources from its own settings.
345
+ const runtimeSources = name === "default" ? [] : await rootRuntimeSources(root, rootSettings);
346
+ const extensionArgs = runtimeSources.flatMap((source) => ["--extension", source]);
347
+ const sessionArgs = name === "default" ? [] : ["--session-dir", join(target, "sessions")];
348
+ const piArgs = [resolve(process.argv[1]), ...extensionArgs, ...sessionArgs, ...args];
349
+ // The default profile is intentionally never sandboxed. Named profiles run
350
+ // directly in their own persistent directory; nono enforces their policy.
351
+ const sandbox = await isSandboxEnabled(target, name === "default");
352
+ const childEnv = {
353
+ ...await profileEnvironment(target),
354
+ PI_CODING_AGENT_DIR: target,
355
+ PI_PROFILE_ROOT: root,
356
+ PI_PROFILE_REEXEC: "1",
357
+ PI_ACTIVE_PROFILE: name,
358
+ };
359
+ if (name === "default") delete childEnv.PI_CODING_AGENT_SESSION_DIR;
360
+ else childEnv.PI_CODING_AGENT_SESSION_DIR = join(target, "sessions");
361
+ const launch = sandbox
362
+ ? sandboxedCommand(await ensureProfileSandbox(target, resolve(process.argv[1]), runtimeSources), target, process.execPath, piArgs)
363
+ : { command: process.execPath, args: piArgs };
364
+ const child = spawn(launch.command, launch.args, {
365
+ cwd: sandbox ? target : process.cwd(),
366
+ stdio: "inherit",
367
+ env: childEnv,
368
+ });
369
+ const code = await new Promise<number>((resolveExit, reject) => {
370
+ child.once("error", reject);
371
+ child.once("exit", (exitCode) => resolveExit(exitCode ?? 1));
372
+ });
373
+ process.exit(code);
374
+ }
375
+
376
+ export default async function (pi: ExtensionAPI) {
377
+ // Capture the profile directory when this runtime is built. This also lets SDK
378
+ // consumers host multiple profile runtimes in one process without later
379
+ // process.env changes leaking between agent turns.
380
+ const activeAgentDir = process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir;
381
+ pi.registerFlag("profile", {
382
+ description: "Start Pi using a named profile",
383
+ type: "string",
384
+ });
385
+
386
+ const args = commandArgs(process.argv.slice(2));
387
+ if (await handleRemoteCli(args, rootAgentDir())) process.exit(0);
388
+ const resume = extractResumeCommand(args);
389
+ if (resume) {
390
+ await reexecWithProfile(resume.profile, ["--session", resume.sessionId]);
391
+ }
392
+
393
+ if (args[0] === "profile") {
394
+ await handleProfileCommand(args);
395
+ process.exit(0);
396
+ }
397
+
398
+ const requestedProfile = extractProfile(args);
399
+ const guardrailArgs = requestedProfile?.args ?? args;
400
+ // A profile re-exec has already consumed the `profile <name>` prefix. Keep
401
+ // its active profile for commands dispatched in that child process.
402
+ const selectedProfile = requestedProfile?.name ?? process.env.PI_ACTIVE_PROFILE ?? "default";
403
+ if (guardrailArgs[0] === "guardrails") {
404
+ await handleGuardrailsCli(selectedProfile, guardrailArgs);
405
+ process.exit(0);
406
+ }
407
+ if (guardrailArgs[0] === "pulse") {
408
+ const { handlePulseCli } = await import("./pulse/index.ts");
409
+ await handlePulseCli(guardrailArgs, selectedProfile);
410
+ process.exit(0);
411
+ }
412
+ if (process.env.PI_PROFILE_REEXEC !== "1") {
413
+ await reexecWithProfile(requestedProfile?.name ?? "default", requestedProfile?.args ?? args);
414
+ }
415
+
416
+ pi.on("session_start", async () => {
417
+ if (process.env.PI_PROFILE_DISCOVER_TOOLS === "1") {
418
+ // Used by the host API gateway. Extensions are already loaded at this
419
+ // point, so this reports their tools without ever evaluating them there.
420
+ await writeStdout(`${JSON.stringify({ tools: pi.getAllTools().map((tool) => tool.name) })}\n`);
421
+ process.exit(0);
422
+ }
423
+ const settings = await readJson(join(activeAgentDir, "settings.json")).catch(() => ({}));
424
+ const enabledTools = settings.profile?.enabledTools;
425
+ if (!enabledTools || enabledTools.includes("*")) return;
426
+ const allowed = new Set(enabledTools);
427
+ pi.setActiveTools(pi.getAllTools().filter((tool) => allowed.has(tool.name)).map((tool) => tool.name));
428
+ });
429
+
430
+ pi.on("before_agent_start", async (event) => {
431
+ const sections: string[] = [];
432
+ const soulPath = join(activeAgentDir, "SOUL.md");
433
+ if (existsSync(soulPath)) { const soul = (await readFile(soulPath, "utf8")).trim(); if (soul) sections.push(`<profile_soul path="${soulPath}">\n${soul}\n</profile_soul>`); }
434
+ const handoff = process.env.PI_APPLICATION_HANDOFF?.trim();
435
+ if (handoff) sections.push(`<application_handoff>\nThe following is trusted pending context from the previous Application session. Use it only when it is relevant to the current user message. Do not mention this handoff unless needed to answer the user.\n\n${handoff}\n</application_handoff>`);
436
+ if (!sections.length) return;
437
+ return { systemPrompt: `${event.systemPrompt}\n\n${sections.join("\n\n")}` };
438
+ });
439
+ }
@@ -0,0 +1,62 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { existsSync, openSync } from "node:fs";
4
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { PulseStore } from "./store.ts";
9
+
10
+ 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 alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
14
+ 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 }); }
16
+ 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"));
18
+ 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
+ const file = await visit(directory); if (!file) return undefined;
20
+ const header = JSON.parse((await readFile(file, "utf8")).split("\n", 1)[0]) as { cwd?: string };
21
+ return { file, cwd: header.cwd || process.cwd() };
22
+ }
23
+ 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."); }
26
+ 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; }
29
+ export default async function (pi: ExtensionAPI) {
30
+ if (await handlePulseCli(process.argv.slice(2))) process.exit();
31
+ 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
+ pi.registerTool({
33
+ name: "schedule",
34
+ 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.",
36
+ parameters: Type.Object({
37
+ action: Type.Union([Type.Literal("create"), Type.Literal("list"), Type.Literal("update"), Type.Literal("enable"), Type.Literal("disable"), Type.Literal("delete")]),
38
+ name: Type.Optional(Type.String({ description: "Schedule name. Required except when listing." })),
39
+ description: Type.Optional(Type.String({ description: "Human-readable purpose, required for create." })),
40
+ schedule: Type.Optional(Type.String({ description: "Five-field UTC cron expression or @once:<ISO-8601>. Required for create." })),
41
+ prompt: Type.Optional(Type.String({ description: "Markdown instructions, required for create." })),
42
+ }),
43
+ async execute(_id, params, _signal, _update, ctx) {
44
+ try {
45
+ const profile = process.env.PI_ACTIVE_PROFILE ?? "default", store = new PulseStore(dbPath());
46
+ if (params.action === "list") { const pulses = store.list(profile); return { content: [{ type: "text", text: JSON.stringify(pulses) }], details: { pulses } }; }
47
+ if (!params.name) throw new Error("A schedule name is required.");
48
+ if (params.action === "create") {
49
+ if (!params.description || !params.schedule || !params.prompt) throw new Error("Create requires name, description, schedule, and prompt.");
50
+ 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();
52
+ return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
53
+ }
54
+ 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
+ if (params.action === "delete") { store.delete(params.name, profile); return { content: [{ type: "text", text: `Schedule '${params.name}' deleted.` }], details: {} }; }
56
+ 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 });
58
+ return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
59
+ } catch (error) { return { content: [{ type: "text", text: `Unable to manage schedule: ${error instanceof Error ? error.message : String(error)}` }], details: {}, isError: true }; }
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,105 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+
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 };
8
+ export type PulseHistory = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
9
+ type Row = Record<string, unknown>;
10
+ const iso = (date = new Date()) => date.toISOString();
11
+
12
+ function fieldMatches(field: string, value: number, min: number, max: number): boolean {
13
+ return field.split(",").some((part) => {
14
+ const [range, stepText] = part.split("/"); const step = stepText ? Number(stepText) : 1;
15
+ if (!Number.isInteger(step) || step < 1) return false;
16
+ const values = range === "*" ? [min, max] : range.split("-").map(Number);
17
+ if (values.length > 2 || values.some((item) => !Number.isInteger(item)) || values[0] < min || values[values.length - 1] > max) return false;
18
+ const [from, to] = values.length === 1 ? [values[0], values[0]] : values;
19
+ return value >= from && value <= to && (value - from) % step === 0;
20
+ });
21
+ }
22
+ export function validSchedule(schedule: string): boolean {
23
+ if (/^@once:\d{4}-\d{2}-\d{2}T/.test(schedule)) return !Number.isNaN(Date.parse(schedule.slice(6)));
24
+ const fields = schedule.trim().split(/\s+/); if (fields.length !== 5) return false;
25
+ return [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]].every(([min, max], index) => fields[index].split(",").every((part) => {
26
+ const match = part.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/); if (!match) return false;
27
+ const step = match[2] ? Number(match[2]) : 1; if (!Number.isInteger(step) || step < 1) return false;
28
+ if (match[1] === "*") return true; const values = match[1].split("-").map(Number);
29
+ return values.every((value) => Number.isInteger(value) && value >= min && value <= max) && (values.length === 1 || values[0] <= values[1]);
30
+ }));
31
+ }
32
+ export function nextRun(schedule: string, after = new Date()): Date | null {
33
+ if (schedule.startsWith("@once:")) { const date = new Date(schedule.slice(6)); return date > after ? date : null; }
34
+ if (!validSchedule(schedule)) return null;
35
+ const [minute, hour, day, month, weekDay] = schedule.trim().split(/\s+/);
36
+ const candidate = new Date(after); candidate.setUTCSeconds(0, 0); candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
37
+ for (let count = 0; count < 527_040; count++, candidate.setUTCMinutes(candidate.getUTCMinutes() + 1)) {
38
+ if (fieldMatches(minute, candidate.getUTCMinutes(), 0, 59) && fieldMatches(hour, candidate.getUTCHours(), 0, 23) && fieldMatches(day, candidate.getUTCDate(), 1, 31) && fieldMatches(month, candidate.getUTCMonth() + 1, 1, 12) && fieldMatches(weekDay, candidate.getUTCDay(), 0, 6)) return new Date(candidate);
39
+ }
40
+ return null;
41
+ }
42
+ export function pulseType(schedule: string): PulseType {
43
+ if (schedule.startsWith("@once:")) return "cron";
44
+ const [minute = "", hour = ""] = schedule.trim().split(/\s+/);
45
+ // Classify from the declared interval, not from how a cron implementation
46
+ // wraps an oversized step inside a single field (e.g. */1000).
47
+ const minuteStep = minute.match(/^\*\/(\d+)$/);
48
+ if (minuteStep) return Number(minuteStep[1]) < 240 ? "heartbeat" : "cron";
49
+ const hourStep = hour.match(/^\*\/(\d+)$/);
50
+ if (hourStep && minute === "0") return Number(hourStep[1]) < 4 ? "heartbeat" : "cron";
51
+ const first = nextRun(schedule, new Date("2026-01-01T00:00:00Z")), second = first ? nextRun(schedule, first) : null;
52
+ return first && second && second.getTime() - first.getTime() < 4 * 60 * 60 * 1000 ? "heartbeat" : "cron";
53
+ }
54
+ function heartbeatAllowed(schedule: string): boolean { return pulseType(schedule) === "heartbeat"; }
55
+
56
+ export class PulseStore {
57
+ private db: DatabaseSync;
58
+ constructor(path: string) {
59
+ mkdirSync(dirname(path), { recursive: true }); this.db = new DatabaseSync(path); this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
60
+ 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);
61
+ 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
+ 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);
63
+ 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);`);
64
+ // Migrate databases created before the denormalized latest result column.
65
+ const columns = this.db.prepare("PRAGMA table_info(pulses)").all() as Row[];
66
+ if (!columns.some((column) => column.name === "result")) this.db.exec("ALTER TABLE pulses ADD COLUMN result TEXT");
67
+ 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");
68
+ // Keep records created before the schedule-based classifier in sync.
69
+ 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); }
70
+ }
71
+ 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 }; }
72
+ 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)); }
73
+ get(name: string, profile?: string): Pulse | undefined { return this.list(profile).find((item) => item.name === name); }
74
+ 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) }; }); }
75
+ create(input: Omit<Pulse, "id" | "enabled" | "nextRunAt" | "lastRunAt" | "type" | "result">): Pulse {
76
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(input.name)) throw new Error("Invalid pulse name.");
77
+ if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
78
+ const type = pulseType(input.schedule), id = randomUUID(), next = nextRun(input.schedule)?.toISOString() ?? null;
79
+ 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);
80
+ this.db.prepare("INSERT INTO pulse_control VALUES (?, ?, 1, ?, NULL, ?)").run(id, input.profile, next, iso());
81
+ if (type === "heartbeat") this.db.prepare("INSERT INTO pulse_state VALUES (?, '', ?)").run(id, iso());
82
+ return this.get(input.name, input.profile)!;
83
+ }
84
+ setEnabled(name: string, enabled: boolean, profile?: string): Pulse {
85
+ const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
86
+ const next = enabled ? nextRun(pulse.schedule)?.toISOString() ?? null : null;
87
+ this.db.prepare("UPDATE pulse_control SET enabled=?, next_run_at=?, updated_at=? WHERE pulse_id=?").run(enabled ? 1 : 0, next, iso(), pulse.id);
88
+ return this.get(name, profile)!;
89
+ }
90
+ update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id">): Pulse {
91
+ const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
92
+ if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
93
+ const type = pulseType(input.schedule);
94
+ 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);
95
+ 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);
96
+ return this.get(name, profile)!;
97
+ }
98
+ 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); }
99
+ 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)); }
100
+ 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; }
101
+ 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); }
102
+ 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); }
103
+ handoff(id: string): string { return String((this.db.prepare("SELECT handoff FROM pulse_state WHERE pulse_id=?").get(id) as Row | undefined)?.handoff ?? ""); }
104
+ 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); }
105
+ }