pi-feats 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -9
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +79 -8
- package/extensions/api-server/application-store.ts +23 -10
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +109 -39
- package/extensions/api-server/server.ts +220 -44
- package/extensions/cli-resources.ts +144 -113
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +14 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +4 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +32 -11
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +69 -25
- package/extensions/pulse/index.ts +148 -16
- package/extensions/pulse/store.ts +132 -18
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +21 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
package/extensions/profiles.ts
CHANGED
|
@@ -2,13 +2,15 @@ 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";
|
|
9
9
|
import { ensureNonoAvailable, ensureProfileSandbox, isSandboxEnabled, migrateLegacySandboxRuntime, sandboxedCommand } from "./lib/profile-sandbox.ts";
|
|
10
10
|
import { profileEnvironment } from "./lib/profile-env.ts";
|
|
11
|
+
import { packageExtensions } from "./lib/package-resources.ts";
|
|
11
12
|
import { handleRemoteCli, REMOTE_COMMAND_NAMES } from "./lib/remote-hosts.ts";
|
|
13
|
+
import { withoutTuiModeArgs } from "./lib/cli-tui-mode.ts";
|
|
12
14
|
|
|
13
15
|
type ProfilePolicy = {
|
|
14
16
|
enabledTools?: string[];
|
|
@@ -22,12 +24,19 @@ type ProfilePolicy = {
|
|
|
22
24
|
type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy };
|
|
23
25
|
|
|
24
26
|
const defaultAgentDir = join(homedir(), ".pi", "agent");
|
|
25
|
-
|
|
27
|
+
|
|
28
|
+
// PI_CODING_AGENT_DIR is set by the remote launcher before the profiles
|
|
29
|
+
// extension runs. PI_PROFILE_ROOT takes precedence only after a profile reexec.
|
|
30
|
+
export function resolveRootAgentDir(environment: NodeJS.ProcessEnv = process.env) {
|
|
31
|
+
return environment.PI_PROFILE_ROOT ?? environment.PI_CODING_AGENT_DIR ?? defaultAgentDir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootAgentDir = () => resolveRootAgentDir();
|
|
26
35
|
const profilesDir = () => join(rootAgentDir(), "profiles");
|
|
27
36
|
const profileDir = (name: string) => join(profilesDir(), name);
|
|
28
37
|
const validProfileName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name);
|
|
29
38
|
const reservedProfileNames = new Set([
|
|
30
|
-
"add", "create", "delete", "disable", "enable", "extensions", "guardrails", "list", "open", "packages", "profile", "pulse", "remove", "rename", "resume", "sessions", "skills", "tools", "validate",
|
|
39
|
+
"add", "create", "delete", "disable", "enable", "extensions", "guardrails", "list", "open", "packages", "profile", "pulse", "remove", "rename", "resume", "sessions", "skills", "ssh", "tools", "validate",
|
|
31
40
|
...REMOTE_COMMAND_NAMES,
|
|
32
41
|
]);
|
|
33
42
|
|
|
@@ -99,13 +108,35 @@ async function readJson(path: string): Promise<ProfileSettings> {
|
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
async function packageSkillRoots(root: string): Promise<string[]> {
|
|
112
|
+
let settings: ProfileSettings = {};
|
|
113
|
+
try { settings = await readJson(join(root, "settings.json")); } catch { return []; }
|
|
114
|
+
const roots: string[] = [];
|
|
115
|
+
if (!Array.isArray(settings.packages)) return roots;
|
|
116
|
+
for (const entry of settings.packages) {
|
|
117
|
+
const source = typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry;
|
|
118
|
+
if (typeof source !== "string") continue;
|
|
119
|
+
const packagePath = packageInstallPath(root, source);
|
|
120
|
+
if (!packagePath) continue;
|
|
121
|
+
try {
|
|
122
|
+
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { pi?: { skills?: unknown } };
|
|
123
|
+
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));
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
return roots;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function sharedResources(root: string, policy: ProfilePolicy = {}, localSkillsDir?: string) {
|
|
130
|
+
// Extensions are loaded through Pi package settings. Named profiles need
|
|
131
|
+
// package Skill paths materialized explicitly because their settings omit
|
|
132
|
+
// packages and Pi otherwise only discovers workspace-local skills.
|
|
133
|
+
const selectedSharedSkills = !policy.enabledSkills || policy.enabledSkills.includes("*");
|
|
134
|
+
const packageSkills = policy.skillSources?.shared === false ? [] : (await packageSkillRoots(root)).flatMap((skillRoot) => selectedSharedSkills ? [skillRoot] : policy.enabledSkills!.map((name) => join(skillRoot, name)).filter(existsSync));
|
|
105
135
|
return {
|
|
106
136
|
skills: [
|
|
107
137
|
...(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 ? (
|
|
138
|
+
...(policy.skillSources?.shared !== false ? (selectedSharedSkills ? [join(root, "skills")] : policy.enabledSkills!.map((name) => join(root, "skills", name)).filter(existsSync)) : []),
|
|
139
|
+
...packageSkills,
|
|
109
140
|
],
|
|
110
141
|
prompts: [join(root, "prompts")],
|
|
111
142
|
themes: [join(root, "themes")],
|
|
@@ -129,14 +160,18 @@ function packageInstallPath(root: string, source: string): string | undefined {
|
|
|
129
160
|
return existsSync(join(path, "package.json")) ? path : undefined;
|
|
130
161
|
}
|
|
131
162
|
|
|
132
|
-
|
|
163
|
+
const requiredRuntimeExtensions = new Set(["profiles", "api-server", "conversation-search"]);
|
|
164
|
+
|
|
165
|
+
export async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<string[]> {
|
|
133
166
|
const sources = new Set<string>();
|
|
134
167
|
const enabled = base.profile?.enabledExtensions;
|
|
135
168
|
const extensionName = (path: string) => basename(path).replace(/\.(?:ts|js)$/, "");
|
|
136
|
-
const add = (path: string) => {
|
|
169
|
+
const add = (path: string, aliases = [extensionName(path)]) => {
|
|
137
170
|
if (!existsSync(path)) return;
|
|
138
|
-
|
|
139
|
-
|
|
171
|
+
// Profiles are workspaces, but these extensions are core runtime
|
|
172
|
+
// capabilities and must cross the default-runtime boundary even when a
|
|
173
|
+
// profile limits optional extensions.
|
|
174
|
+
if (aliases.some((name) => requiredRuntimeExtensions.has(name)) || !enabled || enabled.includes("*") || aliases.some((name) => enabled.includes(name))) sources.add(path);
|
|
140
175
|
};
|
|
141
176
|
if (Array.isArray(base.packages)) for (const entry of base.packages) {
|
|
142
177
|
const source = typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry;
|
|
@@ -144,8 +179,8 @@ async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<
|
|
|
144
179
|
const packagePath = packageInstallPath(root, source);
|
|
145
180
|
if (!packagePath) { add(source.startsWith(".") ? resolve(root, source) : source); continue; }
|
|
146
181
|
try {
|
|
147
|
-
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { pi?: { extensions?: unknown } };
|
|
148
|
-
|
|
182
|
+
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { name?: string; pi?: { extensions?: unknown } };
|
|
183
|
+
for (const extension of packageExtensions([{ base: packagePath, name: manifest.name ?? basename(packagePath), manifest }])) add(extension.path, extension.aliases);
|
|
149
184
|
} catch {}
|
|
150
185
|
}
|
|
151
186
|
if (Array.isArray(base.extensions)) for (const entry of base.extensions) if (typeof entry === "string") add(entry.startsWith(".") ? resolve(root, entry) : entry);
|
|
@@ -162,11 +197,11 @@ async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<
|
|
|
162
197
|
return [...sources];
|
|
163
198
|
}
|
|
164
199
|
|
|
165
|
-
function profileSettings(root: string, base: ProfileSettings, localSkillsDir: string): ProfileSettings {
|
|
200
|
+
async function profileSettings(root: string, base: ProfileSettings, localSkillsDir: string): Promise<ProfileSettings> {
|
|
166
201
|
const { packages: _packages, extensions: _extensions, ...profileBase } = base;
|
|
167
202
|
return {
|
|
168
203
|
...profileBase,
|
|
169
|
-
...sharedResources(root, { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false } }, localSkillsDir),
|
|
204
|
+
...await sharedResources(root, { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false } }, localSkillsDir),
|
|
170
205
|
defaultTools: ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"],
|
|
171
206
|
profile: {
|
|
172
207
|
enabledTools: ["*"],
|
|
@@ -227,6 +262,12 @@ async function removeProfileRuntimeArtifacts(destination: string) {
|
|
|
227
262
|
await Promise.all(["extensions", "git", "npm", "node_modules"].map((name) => rm(join(destination, name), { recursive: true, force: true })));
|
|
228
263
|
}
|
|
229
264
|
|
|
265
|
+
async function linkSharedModels(root: string, destination: string): Promise<void> {
|
|
266
|
+
const source = join(root, "models.json"), target = join(destination, "models.json");
|
|
267
|
+
await rm(target, { force: true });
|
|
268
|
+
if (existsSync(source)) await symlink(source, target);
|
|
269
|
+
}
|
|
270
|
+
|
|
230
271
|
async function createProfile(name: string) {
|
|
231
272
|
if (!validProfileName(name)) fail("invalid name; use letters, numbers, hyphens, or underscores (max. 64 characters).");
|
|
232
273
|
if (name === "default") fail("default is the primary profile and cannot be created.");
|
|
@@ -239,7 +280,7 @@ async function createProfile(name: string) {
|
|
|
239
280
|
await mkdir(join(destination, "sessions"), { recursive: true });
|
|
240
281
|
const baseSettingsPath = join(root, "settings.json");
|
|
241
282
|
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`);
|
|
283
|
+
await writeFile(join(destination, "settings.json"), `${JSON.stringify({ ...await profileSettings(root, base, join(destination, "skills")), sandbox: true }, null, 2)}\n`);
|
|
243
284
|
await ensureProfileSandbox(destination, resolve(process.argv[1]));
|
|
244
285
|
await writeFile(join(destination, "SOUL.md"), "");
|
|
245
286
|
await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n");
|
|
@@ -247,10 +288,7 @@ async function createProfile(name: string) {
|
|
|
247
288
|
const authPath = join(root, "auth.json");
|
|
248
289
|
if (existsSync(authPath)) await copyFile(authPath, join(destination, "auth.json"));
|
|
249
290
|
|
|
250
|
-
|
|
251
|
-
if (existsSync(modelsPath)) {
|
|
252
|
-
await symlink(modelsPath, join(destination, "models.json"));
|
|
253
|
-
}
|
|
291
|
+
await linkSharedModels(root, destination);
|
|
254
292
|
|
|
255
293
|
await writeStdout(`Profile '${name}' created at ${destination}\n`);
|
|
256
294
|
}
|
|
@@ -276,6 +314,9 @@ async function handleProfileCommand(args: string[]) {
|
|
|
276
314
|
// Accept the natural profile-first forms too. This is particularly useful
|
|
277
315
|
// for remote operation, where a session ID is copied from `sessions list`.
|
|
278
316
|
if (action && !reservedProfileNames.has(action.toLowerCase())) {
|
|
317
|
+
// Profile-first shorthand for SSH registration. Keep the explicit
|
|
318
|
+
// `pi profile <name> ssh add <alias>` form as the canonical spelling.
|
|
319
|
+
if (args[2] === "add" && args.length === 4) return reexecWithProfile(action, ["ssh", "add", args[3]]);
|
|
279
320
|
if (args[2] === "resume" && args.length === 3) return reexecWithProfile(action, ["--resume"]);
|
|
280
321
|
if ((args[2] === "resume" || args[2] === "open") && args.length === 4) return reexecWithProfile(action, ["--session", args[3]]);
|
|
281
322
|
return reexecWithProfile(action, args.slice(2));
|
|
@@ -290,11 +331,11 @@ function commandArgs(raw: string[]): string[] {
|
|
|
290
331
|
const result: string[] = [];
|
|
291
332
|
for (let index = 0; index < raw.length; index += 1) {
|
|
292
333
|
const value = raw[index];
|
|
293
|
-
if (value === "--extension" || value === "--session-dir") { index += 1; continue; }
|
|
294
|
-
if (value.startsWith("--extension=") || value.startsWith("--session-dir=")) continue;
|
|
334
|
+
if (value === "--extension" || value === "--session-dir" || value === "--tui-mode") { index += 1; continue; }
|
|
335
|
+
if (value.startsWith("--extension=") || value.startsWith("--session-dir=") || value.startsWith("--tui-mode=")) continue;
|
|
295
336
|
result.push(value);
|
|
296
337
|
}
|
|
297
|
-
return result;
|
|
338
|
+
return withoutTuiModeArgs(result);
|
|
298
339
|
}
|
|
299
340
|
|
|
300
341
|
function extractResumeCommand(args: string[]) {
|
|
@@ -335,9 +376,10 @@ async function syncProfileResources(name: string) {
|
|
|
335
376
|
delete settings.packages;
|
|
336
377
|
delete settings.extensions;
|
|
337
378
|
if (settings.profile) delete settings.profile.enabledExtensions;
|
|
338
|
-
Object.assign(settings, sharedResources(root, settings.profile, join(profileDir(name), "skills")));
|
|
379
|
+
Object.assign(settings, await sharedResources(root, settings.profile, join(profileDir(name), "skills")));
|
|
339
380
|
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
340
381
|
await removeProfileRuntimeArtifacts(profileDir(name));
|
|
382
|
+
await linkSharedModels(root, profileDir(name));
|
|
341
383
|
}
|
|
342
384
|
|
|
343
385
|
async function reexecWithProfile(name: string, args: string[]) {
|
|
@@ -435,7 +477,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
435
477
|
const enabledTools = settings.profile?.enabledTools;
|
|
436
478
|
if (!enabledTools || enabledTools.includes("*")) return;
|
|
437
479
|
const allowed = new Set(enabledTools);
|
|
438
|
-
|
|
480
|
+
// Conversation search is a package-wide profile capability, even when a
|
|
481
|
+
// profile limits optional tools to an explicit allow-list.
|
|
482
|
+
pi.setActiveTools(pi.getAllTools().filter((tool) => allowed.has(tool.name) || tool.name === "conversation_search").map((tool) => tool.name));
|
|
439
483
|
});
|
|
440
484
|
|
|
441
485
|
pi.on("before_agent_start", async (event) => {
|
|
@@ -1,44 +1,176 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { openSync } from "node:fs";
|
|
5
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
9
|
import { PulseStore } from "./store.ts";
|
|
10
|
+
import { ApplicationStore } from "../api-server/application-store.ts";
|
|
11
|
+
import { withoutTuiModeArgs } from "../lib/cli-tui-mode.ts";
|
|
12
|
+
import { piCommand } from "../lib/pi-command.ts";
|
|
9
13
|
|
|
10
14
|
const root = () => process.env.PI_PROFILE_ROOT ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
11
|
-
const dbPath = () => join(root(), "pulse.db");
|
|
12
|
-
|
|
15
|
+
const dbPath = () => join(root(), "pulse.db");
|
|
16
|
+
const statePath = () => join(root(), "pulse-tick.state.json");
|
|
17
|
+
const logPath = () => join(root(), "pulse-tick.log");
|
|
18
|
+
const TICK_INTERVAL_MS = 60_000;
|
|
19
|
+
type State = { pid: number; owner?: string; startedAt: string; lastHeartbeatAt?: string };
|
|
13
20
|
const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
14
21
|
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 }); }
|
|
22
|
+
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" }); }
|
|
23
|
+
function tickLog(message: string) { console.error(`[${new Date().toISOString()}] Pulse Tick: ${message}`); }
|
|
24
|
+
|
|
25
|
+
/** The SQLite lease is the singleton authority; the state file is diagnostic only. */
|
|
26
|
+
async function acquireTickLock(store: PulseStore): Promise<{ owner: string; heartbeat: () => Promise<void>; release: () => Promise<void> } | undefined> {
|
|
27
|
+
const owner = randomUUID();
|
|
28
|
+
if (!store.claimTickLease(owner)) return undefined;
|
|
29
|
+
const state: State = { pid: process.pid, owner, startedAt: new Date().toISOString(), lastHeartbeatAt: new Date().toISOString() };
|
|
30
|
+
await write(statePath(), state);
|
|
31
|
+
return { owner, heartbeat: async () => {
|
|
32
|
+
const current = await json<State>(statePath());
|
|
33
|
+
if (current?.owner === owner) await write(statePath(), { ...current, lastHeartbeatAt: new Date().toISOString() });
|
|
34
|
+
}, release: async () => {
|
|
35
|
+
store.releaseTickLease(owner);
|
|
36
|
+
const current = await json<State>(statePath());
|
|
37
|
+
if (current?.owner === owner) await unlink(statePath()).catch(() => {});
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function profileModelArgs(profile: string, agentDir = root()): Promise<string[]> {
|
|
42
|
+
const settingsPath = profile === "default" ? join(agentDir, "settings.json") : join(agentDir, "profiles", profile, "settings.json");
|
|
43
|
+
const settings = await json<Record<string, unknown>>(settingsPath);
|
|
44
|
+
const provider = typeof settings?.defaultProvider === "string" ? settings.defaultProvider.trim() : "";
|
|
45
|
+
const model = typeof settings?.defaultModel === "string" ? settings.defaultModel.trim() : "";
|
|
46
|
+
return provider && model ? ["--provider", provider, "--model", model] : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sessionDirectory(profile: string): string { return join(root(), profile === "default" ? "sessions" : join("profiles", profile, "sessions")); }
|
|
50
|
+
export function pulseSessionId(type: "cron" | "heartbeat", date = new Date()): string { return `pulse-${type}_${date.toISOString().slice(0, 16).replace("T", "-").replace(":", "-")}`; }
|
|
51
|
+
|
|
16
52
|
async function sessionFile(profile: string, id: string): Promise<{ file: string; cwd: string } | undefined> {
|
|
17
|
-
const directory =
|
|
53
|
+
const directory = sessionDirectory(profile);
|
|
18
54
|
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
55
|
const file = await visit(directory); if (!file) return undefined;
|
|
20
56
|
const header = JSON.parse((await readFile(file, "utf8")).split("\n", 1)[0]) as { cwd?: string };
|
|
21
57
|
return { file, cwd: header.cwd || process.cwd() };
|
|
22
58
|
}
|
|
59
|
+
async function createPulseSession(profile: string, type: "cron" | "heartbeat", cwd: string): Promise<{ id: string; file: string }> {
|
|
60
|
+
const base = pulseSessionId(type); let id = base, suffix = 2;
|
|
61
|
+
while (await sessionFile(profile, id)) id = `${base}-${String(suffix++).padStart(2, "0")}`;
|
|
62
|
+
const session = SessionManager.create(cwd, sessionDirectory(profile), { id });
|
|
63
|
+
session.appendSessionInfo(id);
|
|
64
|
+
const file = session.getSessionFile();
|
|
65
|
+
if (!file) throw new Error("Unable to create the Pulse execution session.");
|
|
66
|
+
return { id, file };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function appendPulseResult(profile: string, sessionId: string, content: string, details: Record<string, unknown>): Promise<void> {
|
|
70
|
+
const target = await sessionFile(profile, sessionId);
|
|
71
|
+
if (!target) throw new Error(`Session '${sessionId}' was not found for profile '${profile}'.`);
|
|
72
|
+
SessionManager.open(target.file, sessionDirectory(profile), target.cwd).appendCustomMessageEntry("pulse", content, true, details);
|
|
73
|
+
}
|
|
74
|
+
|
|
23
75
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async function
|
|
28
|
-
|
|
76
|
+
const persistentCommand = "dir=$(mktemp -d) || exit 1; fifo=$dir/stdin; mkfifo \"$fifo\" || { rmdir \"$dir\"; exit 1; }; cleanup() { kill \"$tail_pid\" 2>/dev/null || true; wait \"$tail_pid\" 2>/dev/null || true; rm -f \"$fifo\"; rmdir \"$dir\"; }; trap cleanup EXIT HUP INT TERM; tail -f /dev/null >\"$fifo\" & tail_pid=$!; \"$@\" <\"$fifo\"; exit $?";
|
|
77
|
+
const completionContract = "\n\nPulse completion contract (internal):\nAfter completing the requested work and any tool calls, always return a non-empty final response for the Pulse execution log. This final response is internal: do not send it to an external recipient unless the task explicitly asks you to do so. Instructions not to send text to a recipient restrict outbound communication only; they do not permit an empty final response. State the outcome concisely, including any relevant identifier or result.";
|
|
78
|
+
export function pulseMessage(name: string, prompt: string, handoff = "") { return `[Pulse: ${name}]\n${prompt}${handoff ? `\n\nPrevious heartbeat state (private):\n${handoff}` : ""}${completionContract}`; }
|
|
79
|
+
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}).`); if (current) console.error(`Pulse tick found abandoned state for PID ${current.pid}; its last heartbeat was ${current.lastHeartbeatAt ?? "unknown"}.`); const fd = openSync(logPath(), "a"), pi = piCommand(); const child = spawn("sh", ["-c", persistentCommand, "pi-pulse-tick", pi.command, ...pi.args, "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}).`); }
|
|
80
|
+
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}).`); }
|
|
81
|
+
async function restart() {
|
|
82
|
+
const current = await json<State>(statePath());
|
|
83
|
+
await stop();
|
|
84
|
+
if (current) for (let attempt = 0; attempt < 50 && alive(current.pid); attempt++) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
85
|
+
if (current && alive(current.pid)) throw new Error(`Pulse tick (PID ${current.pid}) did not stop within 5 seconds.`);
|
|
86
|
+
await start();
|
|
87
|
+
}
|
|
88
|
+
async function status() { const state = await json<State>(statePath()); const store = new PulseStore(dbPath()); const enabled = store.list().filter((item) => item.enabled).length; if (!state) return console.log(`Pulse tick: stopped (${enabled} enabled pulses)`); if (!alive(state.pid)) return console.log(`Pulse tick: abandoned (PID ${state.pid} is not running; last heartbeat ${state.lastHeartbeatAt ?? "unknown"})`); console.log(`Pulse tick: running (PID ${state.pid}, ${enabled} enabled pulses, since ${state.startedAt}; last heartbeat ${state.lastHeartbeatAt ?? "unknown"})`); }
|
|
89
|
+
async function executePulse(store: PulseStore, pulse: Awaited<ReturnType<PulseStore["claimDue"]>>[number], owner: string) {
|
|
90
|
+
const startedAt = Date.now(); tickLog(`Starting Pulse '${pulse.pulse.name}' (${pulse.runId}).`);
|
|
91
|
+
const renew = setInterval(() => { store.renewClaim(pulse.pulse.id, pulse.runId, owner); store.claimTickLease(owner); }, 30_000);
|
|
92
|
+
try {
|
|
93
|
+
const handoff = pulse.pulse.type === "heartbeat" ? store.handoff(pulse.pulse.id) : "";
|
|
94
|
+
const message = pulseMessage(pulse.pulse.name, pulse.pulse.prompt, handoff);
|
|
95
|
+
const source = pulse.pulse.thread_session_id ? await sessionFile(pulse.pulse.profile, pulse.pulse.thread_session_id) : undefined;
|
|
96
|
+
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}'.`);
|
|
97
|
+
const execution = await createPulseSession(pulse.pulse.profile, pulse.pulse.type, source?.cwd ?? process.cwd());
|
|
98
|
+
store.setRunSession(pulse.runId, execution.id);
|
|
99
|
+
const modelArgs = await profileModelArgs(pulse.pulse.profile);
|
|
100
|
+
const result = await new Promise<string>((resolve, reject) => {
|
|
101
|
+
const pi = piCommand();
|
|
102
|
+
const child = spawn(pi.command, [...pi.args, "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"] });
|
|
103
|
+
let output = "", error = "";
|
|
104
|
+
child.stdout.on("data", (data) => output += String(data)); child.stderr.on("data", (data) => error += String(data));
|
|
105
|
+
child.once("exit", (code) => code === 0 ? resolve(output.trim()) : reject(new Error(error.trim() || `Pi exited with ${code}`)));
|
|
106
|
+
child.once("error", reject);
|
|
107
|
+
});
|
|
108
|
+
const content = `[Pulse: ${pulse.pulse.name}]\n${result}`;
|
|
109
|
+
const details = { pulseId: pulse.pulse.id, pulseName: pulse.pulse.name, runId: pulse.runId, executionSessionId: execution.id, type: pulse.pulse.type, completedAt: new Date().toISOString() };
|
|
110
|
+
if (pulse.pulse.thread_session_id) await appendPulseResult(pulse.pulse.profile, pulse.pulse.thread_session_id, content, details);
|
|
111
|
+
if (pulse.pulse.insertIntoApiSession) {
|
|
112
|
+
const applications = new ApplicationStore(join(root(), "applications.db"));
|
|
113
|
+
for (const active of applications.activeSessions({ profile: pulse.pulse.profile })) {
|
|
114
|
+
try { await appendPulseResult(pulse.pulse.profile, active.sessionId, content, { ...details, application: active.application }); }
|
|
115
|
+
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)}`); }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
store.complete(pulse.pulse, pulse.runId, result, pulse.pulse.type === "heartbeat" ? result.slice(-8000) : undefined, owner);
|
|
119
|
+
tickLog(`Pulse '${pulse.pulse.name}' (${pulse.runId}) completed in ${Date.now() - startedAt}ms.`);
|
|
120
|
+
} catch (error) { const message = error instanceof Error ? error.message : String(error); tickLog(`Pulse '${pulse.pulse.name}' (${pulse.runId}) failed after ${Date.now() - startedAt}ms: ${message}`); store.fail(pulse.pulse, pulse.runId, message, owner); }
|
|
121
|
+
finally { clearInterval(renew); }
|
|
122
|
+
}
|
|
123
|
+
async function tick() {
|
|
124
|
+
const store = new PulseStore(dbPath());
|
|
125
|
+
const lock = await acquireTickLock(store);
|
|
126
|
+
if (!lock) return console.log("Pulse tick is already running.");
|
|
127
|
+
const { owner, heartbeat, release } = lock;
|
|
128
|
+
tickLog(`Started (PID ${process.pid}, owner ${owner}).`);
|
|
129
|
+
const expiredClaims = store.expiredClaims();
|
|
130
|
+
if (expiredClaims) console.error(`Pulse tick found ${expiredClaims} expired active claim(s). Run 'pi pulse recover' only after confirming their workers are gone.`);
|
|
131
|
+
let stopped = false, wake: (() => void) | undefined;
|
|
132
|
+
const active = new Set<Promise<void>>();
|
|
133
|
+
const launch = (pulse: Awaited<ReturnType<PulseStore["claimDue"]>>[number]) => {
|
|
134
|
+
let task: Promise<void>;
|
|
135
|
+
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));
|
|
136
|
+
active.add(task);
|
|
137
|
+
};
|
|
138
|
+
const close = (signal: string) => { tickLog(`Received ${signal}; waiting for ${active.size} active Pulse(s).`); stopped = true; wake?.(); };
|
|
139
|
+
process.once("SIGTERM", () => close("SIGTERM")); process.once("SIGINT", () => close("SIGINT"));
|
|
140
|
+
process.once("unhandledRejection", (reason) => { tickLog(`Unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`); close("unhandled rejection"); });
|
|
141
|
+
process.once("uncaughtException", (error) => { tickLog(`Uncaught exception: ${error.stack ?? error.message}`); close("uncaught exception"); });
|
|
142
|
+
process.once("exit", (code) => tickLog(`Exiting with code ${code}.`));
|
|
143
|
+
try {
|
|
144
|
+
while (!stopped) {
|
|
145
|
+
if (!store.claimTickLease(owner)) { tickLog("Lost the SQLite lease; stopping."); break; }
|
|
146
|
+
await heartbeat();
|
|
147
|
+
// Claiming is atomic and exclusive per Pulse. Workers are intentionally
|
|
148
|
+
// launched independently so unrelated scheduled work can overlap.
|
|
149
|
+
for (const pulse of store.claimDue(undefined, owner)) { if (stopped) break; launch(pulse); }
|
|
150
|
+
await new Promise<void>((resolve) => { wake = resolve; setTimeout(resolve, TICK_INTERVAL_MS); }); wake = undefined;
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
// Do not release the scheduler lease while this tick still owns work.
|
|
154
|
+
await Promise.allSettled([...active]);
|
|
155
|
+
await release();
|
|
156
|
+
tickLog("Stopped cleanly.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
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
160
|
export default async function (pi: ExtensionAPI) {
|
|
30
|
-
if (await handlePulseCli(process.argv.slice(2))) process.exit();
|
|
161
|
+
if (await handlePulseCli(withoutTuiModeArgs(process.argv.slice(2)))) process.exit();
|
|
31
162
|
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
163
|
pi.registerTool({
|
|
33
164
|
name: "schedule",
|
|
34
165
|
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.",
|
|
166
|
+
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. Results are recorded in active Application sessions by default; set insertIntoApiSession=false only when the user explicitly requests not to record them. This adds context only and does not send through an outbound handler.",
|
|
36
167
|
parameters: Type.Object({
|
|
37
168
|
action: Type.Union([Type.Literal("create"), Type.Literal("list"), Type.Literal("update"), Type.Literal("enable"), Type.Literal("disable"), Type.Literal("delete")]),
|
|
38
169
|
name: Type.Optional(Type.String({ description: "Schedule name. Required except when listing." })),
|
|
39
170
|
description: Type.Optional(Type.String({ description: "Human-readable purpose, required for create." })),
|
|
40
171
|
schedule: Type.Optional(Type.String({ description: "Five-field UTC cron expression or @once:<ISO-8601>. Required for create." })),
|
|
41
172
|
prompt: Type.Optional(Type.String({ description: "Markdown instructions, required for create." })),
|
|
173
|
+
insertIntoApiSession: Type.Optional(Type.Boolean({ description: "Defaults to true. Set false only when the user explicitly asks not to record the Pulse result in every active Application session for this profile. This records context only; it does not send through an outbound handler." })),
|
|
42
174
|
}),
|
|
43
175
|
async execute(_id, params, _signal, _update, ctx) {
|
|
44
176
|
try {
|
|
@@ -48,13 +180,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
48
180
|
if (params.action === "create") {
|
|
49
181
|
if (!params.description || !params.schedule || !params.prompt) throw new Error("Create requires name, description, schedule, and prompt.");
|
|
50
182
|
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();
|
|
183
|
+
const pulse = store.create({ name: params.name, description: params.description, schedule: params.schedule, prompt: params.prompt, insertIntoApiSession: params.insertIntoApiSession !== false, profile, thread_session_id: threadSessionId }); await start();
|
|
52
184
|
return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
|
|
53
185
|
}
|
|
54
186
|
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
187
|
if (params.action === "delete") { store.delete(params.name, profile); return { content: [{ type: "text", text: `Schedule '${params.name}' deleted.` }], details: {} }; }
|
|
56
188
|
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 });
|
|
189
|
+
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
190
|
return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
|
|
59
191
|
} catch (error) { return { content: [{ type: "text", text: `Unable to manage schedule: ${error instanceof Error ? error.message : String(error)}` }], details: {}, isError: true }; }
|
|
60
192
|
},
|