@tplog/pi-zendy 0.2.17

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.
@@ -0,0 +1,224 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { createInterface } from "node:readline";
6
+ // ── Helpers ────────────────────────────────────────────────────────────
7
+ function exec(cmd, args, timeoutMs = 5000) {
8
+ return new Promise((resolve) => {
9
+ execFile(cmd, args, { timeout: timeoutMs, encoding: "utf-8" }, (err, stdout, stderr) => {
10
+ if (err && err.code === "ENOENT") {
11
+ resolve({ code: 127, stdout: "", stderr: "" });
12
+ return;
13
+ }
14
+ // execFile callback err has an `exit code` when the process exits non-zero
15
+ const exitCode = err ? (err.status ?? 1) : 0;
16
+ resolve({ code: exitCode, stdout: stdout ?? "", stderr: stderr ?? "" });
17
+ });
18
+ });
19
+ }
20
+ // ── Individual checks ──────────────────────────────────────────────────
21
+ // Common API key env vars that pi recognizes
22
+ const PI_API_KEY_VARS = [
23
+ "ANTHROPIC_API_KEY",
24
+ "ANTHROPIC_OAUTH_TOKEN",
25
+ "OPENAI_API_KEY",
26
+ "GEMINI_API_KEY",
27
+ "GROQ_API_KEY",
28
+ "OPENROUTER_API_KEY",
29
+ "XAI_API_KEY",
30
+ "MISTRAL_API_KEY",
31
+ "AWS_ACCESS_KEY_ID",
32
+ ];
33
+ async function checkPi() {
34
+ const base = {
35
+ name: "pi",
36
+ label: "AI Agent (pi)",
37
+ level: "fatal",
38
+ };
39
+ const result = await exec("pi", ["--version"]);
40
+ if (result.code === 127) {
41
+ return {
42
+ ...base,
43
+ status: "missing",
44
+ hint: "Install pi: npm install -g @earendil-works/pi-coding-agent",
45
+ };
46
+ }
47
+ // pi is installed — check if any auth is configured.
48
+ // Auth can be in ~/.pi/agent/auth.json (OAuth login) or via API key env vars.
49
+ const authFile = join(homedir(), ".pi", "agent", "auth.json");
50
+ const hasFileAuth = existsSync(authFile);
51
+ const hasEnvAuth = PI_API_KEY_VARS.some((v) => !!process.env[v]);
52
+ if (!hasFileAuth && !hasEnvAuth) {
53
+ return {
54
+ ...base,
55
+ status: "auth_error",
56
+ hint: "pi is installed but no provider is configured.\nRun: pi (to log in interactively)\nOr set an API key: export GEMINI_API_KEY=...",
57
+ };
58
+ }
59
+ return { ...base, status: "ok", hint: "" };
60
+ }
61
+ async function checkZcli() {
62
+ const base = {
63
+ name: "zcli",
64
+ label: "Zendesk access",
65
+ level: "core",
66
+ };
67
+ // `zcli whoami` calls GET /api/v2/users/me.json — succeeds only if zcli is
68
+ // installed, configured, and the Zendesk credentials are actually accepted.
69
+ const result = await exec("zcli", ["whoami"], 8000);
70
+ if (result.code === 127) {
71
+ return {
72
+ ...base,
73
+ status: "missing",
74
+ hint: "Install zcli: npm install -g @tplog/zendesk-cli\nThen run: zcli configure",
75
+ };
76
+ }
77
+ if (result.code === 0) {
78
+ return { ...base, status: "ok", hint: "" };
79
+ }
80
+ // zcli prints "Not configured..." to stderr from getConfig() when creds are absent.
81
+ if (result.stderr.includes("Not configured")) {
82
+ return {
83
+ ...base,
84
+ status: "auth_error",
85
+ hint: "zcli is installed but Zendesk credentials are not configured.\nRun: zcli configure",
86
+ };
87
+ }
88
+ // Configured but the API call failed — likely bad creds (401) or network issue.
89
+ return {
90
+ ...base,
91
+ status: "auth_error",
92
+ hint: "zcli is configured but the Zendesk API check failed.\nRun `zcli whoami` to see the error (check credentials and network).",
93
+ };
94
+ }
95
+ async function checkZendeskKg() {
96
+ const base = {
97
+ name: "zendesk-kg",
98
+ label: "Zendesk Knowledge Graph",
99
+ level: "enhanced",
100
+ };
101
+ const result = await exec("zendesk-kg", ["--version"]);
102
+ if (result.code === 127) {
103
+ return {
104
+ ...base,
105
+ status: "missing",
106
+ hint: "zendesk-kg not installed. Install with:\ncurl -fsSL https://raw.githubusercontent.com/sorphwer/zendesk-kg-cli-release/main/install.sh | sh\nThen run: zendesk-kg init",
107
+ };
108
+ }
109
+ return { ...base, status: "ok", hint: "" };
110
+ }
111
+ async function checkGithub() {
112
+ const base = {
113
+ name: "github",
114
+ label: "GitHub access",
115
+ level: "enhanced",
116
+ };
117
+ const result = await exec("ssh", ["-T", "-o", "ConnectTimeout=3", "-o", "StrictHostKeyChecking=no", "git@github.com"]);
118
+ // ssh -T git@github.com exits 1 on success (GitHub prints "Hi user!")
119
+ // exits 255 on auth failure, and 127 if ssh missing
120
+ if (result.code === 127) {
121
+ return {
122
+ ...base,
123
+ status: "missing",
124
+ hint: "SSH client not found. Install OpenSSH to enable source code analysis.",
125
+ };
126
+ }
127
+ const combined = result.stdout + result.stderr;
128
+ if (combined.includes("successfully authenticated") || combined.includes("Hi ")) {
129
+ return { ...base, status: "ok", hint: "" };
130
+ }
131
+ return {
132
+ ...base,
133
+ status: "auth_error",
134
+ hint: "GitHub SSH not configured. Source code analysis will be unavailable.\nSet up SSH keys: https://docs.github.com/en/authentication/connecting-to-github-with-ssh",
135
+ };
136
+ }
137
+ // ── Preflight runner ───────────────────────────────────────────────────
138
+ export async function runPreflight() {
139
+ const results = await Promise.all([checkPi(), checkZcli(), checkZendeskKg(), checkGithub()]);
140
+ const failed = results.filter((r) => r.status !== "ok");
141
+ return {
142
+ results,
143
+ hasFatal: failed.some((r) => r.level === "fatal"),
144
+ hasCore: failed.some((r) => r.level === "core"),
145
+ hasEnhanced: failed.some((r) => r.level === "enhanced"),
146
+ };
147
+ }
148
+ // ── Terminal display ───────────────────────────────────────────────────
149
+ const RESET = "\x1b[0m";
150
+ const BOLD = "\x1b[1m";
151
+ const RED = "\x1b[31m";
152
+ const YELLOW = "\x1b[33m";
153
+ const GREEN = "\x1b[32m";
154
+ const DIM = "\x1b[2m";
155
+ function statusIcon(status) {
156
+ if (status === "ok")
157
+ return `${GREEN}✓${RESET}`;
158
+ return `${RED}✗${RESET}`;
159
+ }
160
+ export function printPreflightReport(report) {
161
+ const failed = report.results.filter((r) => r.status !== "ok");
162
+ if (failed.length === 0)
163
+ return; // all good, stay silent
164
+ console.error("");
165
+ console.error(`${BOLD}Zendy setup check${RESET}`);
166
+ console.error("");
167
+ for (const r of report.results) {
168
+ console.error(` ${statusIcon(r.status)} ${r.label}`);
169
+ }
170
+ console.error("");
171
+ // Fatal — hard stop
172
+ if (report.hasFatal) {
173
+ const fatal = failed.filter((r) => r.level === "fatal");
174
+ for (const r of fatal) {
175
+ console.error(`${RED}${BOLD}Error:${RESET} ${r.label} is required but not available.`);
176
+ console.error(`${DIM}${r.hint}${RESET}`);
177
+ console.error("");
178
+ }
179
+ return;
180
+ }
181
+ // Core missing — strong warning with options
182
+ if (report.hasCore) {
183
+ const core = failed.filter((r) => r.level === "core");
184
+ for (const r of core) {
185
+ console.error(`${YELLOW}${BOLD}Warning:${RESET} ${r.label} is not configured.`);
186
+ console.error(`${DIM}${r.hint}${RESET}`);
187
+ console.error("");
188
+ }
189
+ }
190
+ // Enhanced missing — light banner
191
+ if (report.hasEnhanced) {
192
+ const enhanced = failed.filter((r) => r.level === "enhanced");
193
+ for (const r of enhanced) {
194
+ console.error(`${DIM}Note: ${r.label} is not configured. ${r.hint.split("\n")[0]}${RESET}`);
195
+ }
196
+ console.error("");
197
+ }
198
+ }
199
+ /**
200
+ * Interactive prompt for core dependency failures.
201
+ * Returns true if the user wants to continue, false to exit.
202
+ */
203
+ export function promptCoreMissing() {
204
+ return new Promise((resolve) => {
205
+ const rl = createInterface({
206
+ input: process.stdin,
207
+ output: process.stderr,
208
+ });
209
+ console.error(`${BOLD}What would you like to do?${RESET}`);
210
+ console.error(` ${BOLD}1${RESET} Continue with limited capabilities`);
211
+ console.error(` ${BOLD}2${RESET} Exit and configure manually`);
212
+ console.error("");
213
+ rl.question(`${DIM}Choose [1/2]: ${RESET}`, (answer) => {
214
+ rl.close();
215
+ const choice = answer.trim();
216
+ if (choice === "2") {
217
+ resolve(false);
218
+ }
219
+ else {
220
+ resolve(true); // default: continue
221
+ }
222
+ });
223
+ });
224
+ }
@@ -0,0 +1,43 @@
1
+ export interface CandidateDir {
2
+ path: string;
3
+ name: string;
4
+ mtimeMs: number;
5
+ sizeBytes: number;
6
+ }
7
+ export interface ClassifyOptions {
8
+ nowMs: number;
9
+ /** Skip any dir whose mtime is within this window (0 disables). Wins over everything. */
10
+ minAgeMs: number;
11
+ /** Only delete dirs older than this (undefined = no age limit, i.e. --all). */
12
+ maxAgeMs?: number;
13
+ /** Keep the N most-recent candidates regardless of age. */
14
+ keepLast: number;
15
+ /** Absolute paths that must never be deleted (e.g. the current session dir). */
16
+ protectedPaths?: string[];
17
+ }
18
+ export interface ClassifyResult {
19
+ toDelete: CandidateDir[];
20
+ kept: CandidateDir[];
21
+ protected: CandidateDir[];
22
+ }
23
+ export interface DeleteResult {
24
+ deleted: string[];
25
+ bytesFreed: number;
26
+ errors: {
27
+ path: string;
28
+ error: string;
29
+ }[];
30
+ }
31
+ export declare function scanCandidates(baseDir: string, patterns: string[]): Promise<CandidateDir[]>;
32
+ export declare function classifyCandidates(candidates: CandidateDir[], opts: ClassifyOptions): ClassifyResult;
33
+ export declare function deleteCandidates(candidates: CandidateDir[]): Promise<DeleteResult>;
34
+ export interface SessionDirInfo {
35
+ pid: number;
36
+ timestamp: number;
37
+ }
38
+ export declare function parseSessionDirName(name: string): SessionDirInfo | null;
39
+ export declare function buildSessionDirName(pid: number, timestamp: number): string;
40
+ export declare function isProcessAlive(pid: number): boolean;
41
+ export declare function findOrphanSessionDirs(baseDir: string): Promise<string[]>;
42
+ export declare function formatBytes(n: number): string;
43
+ export declare function formatAge(ageMs: number): string;
@@ -0,0 +1,188 @@
1
+ // Source clone directory cleanup library.
2
+ //
3
+ // Dify enterprise repositories are private; every extra minute their source
4
+ // sits on local disk is an extra minute of exposure surface. This module
5
+ // provides the primitives to enumerate, classify, and wipe those clones.
6
+ //
7
+ // Used by:
8
+ // - `src/cleanup-src.ts` — the `zendy cleanup-src` CLI subcommand.
9
+ // - `extensions/source-cleanup.ts` — the pi extension. The extension file
10
+ // inlines the tiny helpers it needs so it can be loaded as a self-contained
11
+ // TS file by pi at runtime.
12
+ import { readdir, stat, rm } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ async function dirSize(path) {
15
+ let total = 0;
16
+ const stack = [path];
17
+ while (stack.length) {
18
+ const cur = stack.pop();
19
+ let entries;
20
+ try {
21
+ entries = await readdir(cur, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ continue;
25
+ }
26
+ for (const e of entries) {
27
+ if (e.isSymbolicLink())
28
+ continue;
29
+ const full = join(cur, e.name);
30
+ if (e.isDirectory()) {
31
+ stack.push(full);
32
+ }
33
+ else if (e.isFile()) {
34
+ try {
35
+ const s = await stat(full);
36
+ total += s.size;
37
+ }
38
+ catch {
39
+ // unreadable file — skip
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return total;
45
+ }
46
+ function matchesAnyPattern(name, patterns) {
47
+ return patterns.some((p) => {
48
+ if (p.endsWith("*"))
49
+ return name.startsWith(p.slice(0, -1));
50
+ return name === p;
51
+ });
52
+ }
53
+ export async function scanCandidates(baseDir, patterns) {
54
+ let entries;
55
+ try {
56
+ entries = await readdir(baseDir, { withFileTypes: true });
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ const results = [];
62
+ for (const e of entries) {
63
+ if (!e.isDirectory())
64
+ continue; // skip files + symlinks
65
+ if (!matchesAnyPattern(e.name, patterns))
66
+ continue;
67
+ const full = join(baseDir, e.name);
68
+ let st;
69
+ try {
70
+ st = await stat(full);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ const size = await dirSize(full);
76
+ results.push({ path: full, name: e.name, mtimeMs: st.mtimeMs, sizeBytes: size });
77
+ }
78
+ return results;
79
+ }
80
+ export function classifyCandidates(candidates, opts) {
81
+ const protectedSet = new Set(opts.protectedPaths ?? []);
82
+ const sorted = [...candidates].sort((a, b) => b.mtimeMs - a.mtimeMs);
83
+ const toDelete = [];
84
+ const kept = [];
85
+ const protectedOut = [];
86
+ let keptByKeepLast = 0;
87
+ for (const c of sorted) {
88
+ if (protectedSet.has(c.path)) {
89
+ protectedOut.push(c);
90
+ continue;
91
+ }
92
+ const ageMs = opts.nowMs - c.mtimeMs;
93
+ if (opts.minAgeMs > 0 && ageMs < opts.minAgeMs) {
94
+ protectedOut.push(c);
95
+ continue;
96
+ }
97
+ if (keptByKeepLast < opts.keepLast) {
98
+ kept.push(c);
99
+ keptByKeepLast++;
100
+ continue;
101
+ }
102
+ if (opts.maxAgeMs !== undefined && ageMs < opts.maxAgeMs) {
103
+ kept.push(c);
104
+ continue;
105
+ }
106
+ toDelete.push(c);
107
+ }
108
+ return { toDelete, kept, protected: protectedOut };
109
+ }
110
+ export async function deleteCandidates(candidates) {
111
+ const deleted = [];
112
+ const errors = [];
113
+ let bytesFreed = 0;
114
+ for (const c of candidates) {
115
+ try {
116
+ await rm(c.path, { recursive: true, force: true });
117
+ deleted.push(c.path);
118
+ bytesFreed += c.sizeBytes;
119
+ }
120
+ catch (err) {
121
+ errors.push({ path: c.path, error: err.message });
122
+ }
123
+ }
124
+ return { deleted, bytesFreed, errors };
125
+ }
126
+ export function parseSessionDirName(name) {
127
+ const m = /^zendy-session-(\d+)-(\d+)$/.exec(name);
128
+ if (!m)
129
+ return null;
130
+ return { pid: parseInt(m[1], 10), timestamp: parseInt(m[2], 10) };
131
+ }
132
+ export function buildSessionDirName(pid, timestamp) {
133
+ return `zendy-session-${pid}-${timestamp}`;
134
+ }
135
+ export function isProcessAlive(pid) {
136
+ try {
137
+ process.kill(pid, 0);
138
+ return true;
139
+ }
140
+ catch (err) {
141
+ // EPERM: process exists but we can't signal it; it's still alive.
142
+ return err.code === "EPERM";
143
+ }
144
+ }
145
+ export async function findOrphanSessionDirs(baseDir) {
146
+ let entries;
147
+ try {
148
+ entries = await readdir(baseDir, { withFileTypes: true });
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ const orphans = [];
154
+ for (const e of entries) {
155
+ if (!e.isDirectory())
156
+ continue;
157
+ const info = parseSessionDirName(e.name);
158
+ if (!info)
159
+ continue;
160
+ if (!isProcessAlive(info.pid)) {
161
+ orphans.push(join(baseDir, e.name));
162
+ }
163
+ }
164
+ return orphans;
165
+ }
166
+ // ── Formatting helpers ────────────────────────────────────────────────
167
+ export function formatBytes(n) {
168
+ if (n < 1024)
169
+ return `${n} B`;
170
+ if (n < 1024 * 1024)
171
+ return `${(n / 1024).toFixed(1)} KB`;
172
+ if (n < 1024 * 1024 * 1024)
173
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
174
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
175
+ }
176
+ export function formatAge(ageMs) {
177
+ const s = Math.floor(ageMs / 1000);
178
+ if (s < 60)
179
+ return `${s}s`;
180
+ const m = Math.floor(s / 60);
181
+ if (m < 60)
182
+ return `${m}m`;
183
+ const h = Math.floor(m / 60);
184
+ if (h < 24)
185
+ return `${h}h`;
186
+ const d = Math.floor(h / 24);
187
+ return `${d}d`;
188
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Custom Header Extension
3
+ *
4
+ * Displays "zendy" in ASCII art on startup.
5
+ */
6
+
7
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
8
+
9
+ function getZendyArt(theme: Theme): string[] {
10
+ const c = (text: string) => theme.fg("accent", text);
11
+ const d = (text: string) => theme.fg("dim", text);
12
+ const m = (text: string) => theme.fg("muted", text);
13
+
14
+ return [
15
+ "",
16
+ c("███████╗███████╗███╗ ██╗██████╗ ██╗ ██╗"),
17
+ c("╚══███╔╝██╔════╝████╗ ██║██╔══██╗╚██╗ ██╔╝"),
18
+ c(" ███╔╝ █████╗ ██╔██╗ ██║██║ ██║ ╚████╔╝"),
19
+ c(" ███╔╝ ██╔══╝ ██║╚██╗██║██║ ██║ ╚██╔╝"),
20
+ c("███████╗███████╗██║ ╚████║██████╔╝ ██║"),
21
+ c("╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝ ╚═╝"),
22
+ "",
23
+ m("─── ") + d("Dify Enterprise Copilot") + m(" ───"),
24
+ "",
25
+ ];
26
+ }
27
+
28
+ export default function (pi: ExtensionAPI) {
29
+ pi.on("session_start", async (_event, ctx) => {
30
+ if (ctx.hasUI) {
31
+ ctx.ui.setHeader((_tui, theme) => {
32
+ return {
33
+ render(_width: number): string[] {
34
+ return getZendyArt(theme);
35
+ },
36
+ invalidate() {},
37
+ };
38
+ });
39
+ }
40
+ });
41
+
42
+ pi.registerCommand("restore-header", {
43
+ description: "Restore built-in header",
44
+ handler: async (_args, ctx) => {
45
+ ctx.ui.setHeader(undefined);
46
+ ctx.ui.notify("Built-in header restored", "info");
47
+ },
48
+ });
49
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Source-clone cleanup extension (deterministic, code-level defense).
3
+ *
4
+ * dify-enterprise / dify-enterprise-frontend are PRIVATE repositories.
5
+ * Relying on skill-level ("please remember to clean up") guidance is
6
+ * prompt-level defense — an AI can forget. This extension enforces
7
+ * cleanup in code:
8
+ *
9
+ * - on session_start:
10
+ * · create /tmp/zendy-session-<pid>-<ts>/ (mode 0700)
11
+ * · export its path via process.env.ZENDY_SRC_DIR so bash
12
+ * subprocesses spawned by pi (and the source-check skill)
13
+ * clone into it
14
+ * · sweep orphan session dirs whose owning pid is dead
15
+ *
16
+ * - on session_shutdown AND on process exit / SIGINT / SIGTERM /
17
+ * uncaughtException:
18
+ * · rm -rf this session's dir (idempotent, best-effort)
19
+ *
20
+ * - /cleanup-src slash command inside pi: wipe all dify-* and
21
+ * orphan zendy-session-* dirs on demand.
22
+ *
23
+ * Caveats (do not oversell):
24
+ * - kill -9 / OOM / power loss bypass handlers → rely on the
25
+ * next startup's orphan sweep.
26
+ * - rm -rf is not secure erase. For at-rest confidentiality,
27
+ * disk encryption (FileVault/LUKS) is required.
28
+ */
29
+
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+ import { mkdirSync, rmSync, readdirSync } from "node:fs";
32
+ import { join } from "node:path";
33
+
34
+ const BASE_DIR = "/tmp";
35
+ const SESSION_PREFIX = "zendy-session-";
36
+ const WIPE_PREFIXES = ["dify-", SESSION_PREFIX];
37
+
38
+ function parseSessionDir(name: string): { pid: number } | null {
39
+ const m = /^zendy-session-(\d+)-(\d+)$/.exec(name);
40
+ return m ? { pid: parseInt(m[1]!, 10) } : null;
41
+ }
42
+
43
+ function isAlive(pid: number): boolean {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch (e) {
48
+ return (e as NodeJS.ErrnoException).code === "EPERM";
49
+ }
50
+ }
51
+
52
+ function safeRmrf(path: string): boolean {
53
+ try {
54
+ rmSync(path, { recursive: true, force: true });
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function sweepOrphans(base: string, exclude: string): string[] {
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(base, { withFileTypes: true });
65
+ } catch {
66
+ return [];
67
+ }
68
+ const removed: string[] = [];
69
+ for (const e of entries) {
70
+ if (!e.isDirectory()) continue;
71
+ const info = parseSessionDir(e.name);
72
+ if (!info) continue;
73
+ const full = join(base, e.name);
74
+ if (full === exclude) continue;
75
+ if (isAlive(info.pid)) continue;
76
+ if (safeRmrf(full)) removed.push(full);
77
+ }
78
+ return removed;
79
+ }
80
+
81
+ export default function (pi: ExtensionAPI) {
82
+ const ts = Date.now();
83
+ const sessionDir = join(BASE_DIR, `${SESSION_PREFIX}${process.pid}-${ts}`);
84
+ let cleanedUp = false;
85
+
86
+ function ensureDir(): void {
87
+ try {
88
+ mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
89
+ } catch {
90
+ // non-fatal; clone commands will also create subpaths with mkdir -p
91
+ }
92
+ }
93
+
94
+ function cleanupSession(): void {
95
+ if (cleanedUp) return;
96
+ cleanedUp = true;
97
+ safeRmrf(sessionDir);
98
+ }
99
+
100
+ // Belt-and-suspenders: if session_shutdown doesn't fire (some exit paths
101
+ // skip event dispatch), rely on node's process signals. All idempotent.
102
+ process.on("exit", cleanupSession);
103
+ process.on("SIGINT", () => {
104
+ cleanupSession();
105
+ process.exit(130);
106
+ });
107
+ process.on("SIGTERM", () => {
108
+ cleanupSession();
109
+ process.exit(143);
110
+ });
111
+ process.on("uncaughtException", (err) => {
112
+ cleanupSession();
113
+ // Preserve original Node behavior of non-zero exit + stderr trace.
114
+ // eslint-disable-next-line no-console
115
+ console.error(err);
116
+ process.exit(1);
117
+ });
118
+
119
+ pi.on("session_start", async (_event, ctx) => {
120
+ ensureDir();
121
+ process.env["ZENDY_SRC_DIR"] = sessionDir;
122
+ const removed = sweepOrphans(BASE_DIR, sessionDir);
123
+ if (ctx.hasUI && removed.length > 0) {
124
+ ctx.ui.notify(
125
+ `[source-cleanup] swept ${removed.length} orphan session dir(s)`,
126
+ "info",
127
+ );
128
+ }
129
+ });
130
+
131
+ pi.on("session_shutdown", async () => {
132
+ cleanupSession();
133
+ });
134
+
135
+ pi.registerCommand("cleanup-src", {
136
+ description:
137
+ "Wipe all Dify source clones (/tmp/dify-*) and orphan zendy session dirs",
138
+ handler: async (_args, ctx) => {
139
+ let entries;
140
+ try {
141
+ entries = readdirSync(BASE_DIR, { withFileTypes: true });
142
+ } catch {
143
+ ctx.ui.notify(`[source-cleanup] cannot read ${BASE_DIR}`, "error");
144
+ return;
145
+ }
146
+ const removed: string[] = [];
147
+ for (const e of entries) {
148
+ if (!e.isDirectory()) continue;
149
+ if (!WIPE_PREFIXES.some((p) => e.name.startsWith(p))) continue;
150
+ const full = join(BASE_DIR, e.name);
151
+ if (full === sessionDir) continue; // never nuke our own live session
152
+ if (safeRmrf(full)) removed.push(full);
153
+ }
154
+ ctx.ui.notify(
155
+ removed.length === 0
156
+ ? `[source-cleanup] nothing to remove`
157
+ : `[source-cleanup] removed ${removed.length} dir(s):\n${removed.join("\n")}`,
158
+ "info",
159
+ );
160
+ },
161
+ });
162
+ }