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.
Files changed (60) hide show
  1. package/README.md +90 -9
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-log-store.ts +4 -2
  4. package/extensions/api-server/application-runtime.ts +79 -8
  5. package/extensions/api-server/application-store.ts +23 -10
  6. package/extensions/api-server/index.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +109 -39
  8. package/extensions/api-server/server.ts +220 -44
  9. package/extensions/cli-resources.ts +144 -113
  10. package/extensions/cli-tui-mode.ts +7 -0
  11. package/extensions/context-memory/index.ts +6 -1
  12. package/extensions/conversation-search/index.ts +42 -0
  13. package/extensions/guardrails/index.ts +2 -1
  14. package/extensions/lib/application-context.ts +2 -0
  15. package/extensions/lib/builtin-tools.ts +24 -0
  16. package/extensions/lib/cli-tui-mode.ts +72 -0
  17. package/extensions/lib/context-memory.ts +6 -1
  18. package/extensions/lib/conversation-search.ts +33 -0
  19. package/extensions/lib/package-resources.ts +58 -0
  20. package/extensions/lib/pi-command.ts +11 -0
  21. package/extensions/lib/profile-sandbox.ts +14 -3
  22. package/extensions/lib/remote-hosts.ts +7 -5
  23. package/extensions/lib/workflow-evaluator.ts +113 -0
  24. package/extensions/lib/workflow-harness.ts +409 -43
  25. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  26. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  27. package/extensions/pi-console-webui/app/globals.css +3 -0
  28. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  29. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  30. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  31. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  32. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  33. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  34. package/extensions/pi-console-webui/components/console-page.tsx +4 -0
  35. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  36. package/extensions/pi-console-webui/components/console.tsx +32 -11
  37. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  38. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  39. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  40. package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
  41. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  42. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  43. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  44. package/extensions/pi-console-webui/index.ts +2 -1
  45. package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
  46. package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
  47. package/extensions/pi-console-webui/next.config.js +1 -0
  48. package/extensions/profile-ssh.ts +274 -0
  49. package/extensions/profiles.ts +69 -25
  50. package/extensions/pulse/index.ts +148 -16
  51. package/extensions/pulse/store.ts +132 -18
  52. package/extensions/sequential-workflow.ts +121 -79
  53. package/extensions/skill-sources/store.ts +29 -10
  54. package/package.json +21 -9
  55. package/scripts/build-console-webui.mjs +7 -0
  56. package/scripts/console-webui-cache.mjs +79 -0
  57. package/scripts/ensure-console-build.mjs +43 -0
  58. package/skills/profile-ssh/SKILL.md +65 -0
  59. package/skills/sequential-workflow/SKILL.md +4 -3
  60. package/themes/legacy-dark.json +85 -0
@@ -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
- const rootAgentDir = () => process.env.PI_PROFILE_ROOT ?? defaultAgentDir;
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 sharedResources(root: string, policy: ProfilePolicy = {}, localSkillsDir?: string) {
103
- // Extensions are loaded through Pi package settings. Do not materialize paths
104
- // from the native extensions directory: a Git package lives outside it.
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 ? (!policy.enabledSkills || policy.enabledSkills.includes("*") ? [join(root, "skills")] : policy.enabledSkills.map((name) => join(root, "skills", name)).filter(existsSync)) : []),
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
- async function rootRuntimeSources(root: string, base: ProfileSettings): Promise<string[]> {
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
- const name = extensionName(path);
139
- if (!enabled || enabled.includes("*") || enabled.includes(name)) sources.add(path);
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
- if (Array.isArray(manifest.pi?.extensions)) for (const extension of manifest.pi.extensions) if (typeof extension === "string") add(join(packagePath, extension));
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
- const modelsPath = join(root, "models.json");
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
- pi.setActiveTools(pi.getAllTools().filter((tool) => allowed.has(tool.name)).map((tool) => tool.name));
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { existsSync, openSync } from "node:fs";
4
- import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { openSync } from "node:fs";
5
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
6
7
  import { join } from "node:path";
7
8
  import { spawn } from "node:child_process";
8
9
  import { PulseStore } from "./store.ts";
10
+ import { ApplicationStore } from "../api-server/application-store.ts";
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"); const statePath = () => join(root(), "pulse-tick.state.json"); const logPath = () => join(root(), "pulse-tick.log");
12
- type State = { pid: number; startedAt: string };
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 = join(root(), profile === "default" ? "sessions" : join("profiles", profile, "sessions"));
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
- 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; }
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
  },