@tplog/pi-zendy 0.3.8 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,7 @@ zendy is a single pi extension that provides:
14
14
  - **Slash Commands** — `/zendy-config` to set up credentials, `/zendy-status` to check connectivity.
15
15
  - **Skill** — a `zendy` skill (also `/skill:zendy`) that teaches the agent the ticket-analysis workflow, so "analyze ticket #1959" reliably uses the right tools in the right order.
16
16
  - **Session Safety** — Automatic workspace isolation and cleanup for source code analysis.
17
+ - **Source Registry** — Bundled Dify source repository defaults. Enterprise repos use SSH URLs, so GitHub SSH permissions remain the access gate.
17
18
 
18
19
  Typical workflow:
19
20
 
@@ -73,19 +74,7 @@ The agent can call these tools directly:
73
74
  | `zendy_whoami` | Check the currently authenticated Zendesk identity |
74
75
  | `zendy_helm_get` | Query Helm chart values, images, validation by version |
75
76
  | `zendy_kg_search` | Semantic search over historical tickets |
76
- | `zendy_source_status` | Check source analysis workspace |
77
-
78
- ## Legacy Launcher
79
-
80
- For users of the old `zendy` CLI:
81
-
82
- ```bash
83
- npm install -g @tplog/pi-zendy
84
- zendy
85
- ```
86
-
87
- The legacy launcher starts pi with the zendy extension and system prompt. It still supports
88
- `zendy preflight` and `zendy cleanup-src` as standalone subcommands.
77
+ | `zendy_source_status` | Check source analysis workspace and bundled Dify source repository registry |
89
78
 
90
79
  ## How it works
91
80
 
@@ -93,3 +82,5 @@ zendy registers as a pi extension package. The extension provides tools (callabl
93
82
  slash commands (for human engineers), and session lifecycle hooks (workspace creation, cleanup).
94
83
  All data access goes through direct REST APIs — no `zcli`, `zendesk-kg`, or other CLI tools
95
84
  are required at runtime.
85
+
86
+ For source analysis, zendy ships default repository entries for Dify Enterprise backend/frontend and related public Dify repositories. The Enterprise entries are SSH URLs; users without GitHub SSH access can install zendy, but source clone attempts will fail at Git authentication.
@@ -1,6 +1,7 @@
1
1
  export interface ZendyConfig {
2
2
  zendesk?: ZendeskConfig;
3
3
  zendeskKg?: ZendeskKgConfig;
4
+ sourceRepos?: Record<string, SourceRepoConfig>;
4
5
  }
5
6
  export interface ZendeskConfig {
6
7
  subdomain?: string;
@@ -11,4 +12,11 @@ export interface ZendeskKgConfig {
11
12
  apiUrl?: string;
12
13
  apiKey?: string;
13
14
  }
15
+ export interface SourceRepoConfig {
16
+ url: string;
17
+ description?: string;
18
+ visibility?: "private" | "public";
19
+ area?: "backend" | "frontend" | "oss" | "plugin" | "sandbox";
20
+ }
14
21
  export declare const DEFAULT_KG_API_URL = "https://zendesk-ticket-retriever.vercel.app";
22
+ export declare const DEFAULT_SOURCE_REPOS: Record<string, SourceRepoConfig>;
@@ -1,2 +1,34 @@
1
1
  // Config schema and types for zendy configuration (~/.zendy/config.json).
2
2
  export const DEFAULT_KG_API_URL = "https://zendesk-ticket-retriever.vercel.app";
3
+ export const DEFAULT_SOURCE_REPOS = {
4
+ enterpriseBackend: {
5
+ url: "git@github.com:langgenius/dify-enterprise.git",
6
+ description: "Dify Enterprise backend code",
7
+ visibility: "private",
8
+ area: "backend",
9
+ },
10
+ enterpriseFrontend: {
11
+ url: "git@github.com:langgenius/dify-enterprise-frontend.git",
12
+ description: "Dify Enterprise frontend code",
13
+ visibility: "private",
14
+ area: "frontend",
15
+ },
16
+ difyOss: {
17
+ url: "https://github.com/langgenius/dify.git",
18
+ description: "Open-source Dify repository",
19
+ visibility: "public",
20
+ area: "oss",
21
+ },
22
+ pluginDaemon: {
23
+ url: "https://github.com/langgenius/dify-plugin-daemon.git",
24
+ description: "Dify plugin daemon",
25
+ visibility: "public",
26
+ area: "plugin",
27
+ },
28
+ sandbox: {
29
+ url: "https://github.com/langgenius/dify-sandbox.git",
30
+ description: "Dify sandbox",
31
+ visibility: "public",
32
+ area: "sandbox",
33
+ },
34
+ };
@@ -1,4 +1,4 @@
1
- import type { ZendyConfig } from "./schema.js";
1
+ import { type ZendyConfig } from "./schema.js";
2
2
  export declare function getConfig(): ZendyConfig;
3
3
  export declare function writeConfig(config: ZendyConfig): void;
4
4
  export declare function configPath(): string;
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { homedir } from "node:os";
6
+ import { DEFAULT_SOURCE_REPOS } from "./schema.js";
6
7
  const CONFIG_DIR = join(homedir(), ".zendy");
7
8
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
8
9
  // ── Env overrides ─────────────────────────────────────────────────────
@@ -52,6 +53,10 @@ export function getConfig() {
52
53
  ...fileConfig.zendeskKg,
53
54
  ...envK,
54
55
  },
56
+ sourceRepos: {
57
+ ...DEFAULT_SOURCE_REPOS,
58
+ ...fileConfig.sourceRepos,
59
+ },
55
60
  };
56
61
  }
57
62
  export function writeConfig(config) {
@@ -66,6 +66,9 @@ async function configCommand(_args: string, ctx: {
66
66
  lines.push(
67
67
  `KG: ${c.zendeskKg?.apiKey ? `apiKey=*** (set), apiUrl=${c.zendeskKg.apiUrl || "default"}` : "not configured"}`,
68
68
  );
69
+ const repoEntries = Object.entries(c.sourceRepos ?? {});
70
+ const privateCount = repoEntries.filter(([, repo]) => repo.visibility === "private").length;
71
+ lines.push(`Source repos: ${repoEntries.length} configured (${privateCount} private SSH-gated)`);
69
72
  ctx.ui.notify(`[zendy-config]\n${lines.join("\n")}`, "info");
70
73
  }
71
74
  }
@@ -105,6 +108,10 @@ async function statusCommand(_args: string, ctx: {
105
108
  srcDir ? ` ✓ Source workspace — ${srcDir}` : " - Source workspace — not active (will be created on session start)",
106
109
  );
107
110
 
111
+ const repoEntries = Object.entries(getConfig().sourceRepos ?? {});
112
+ const privateCount = repoEntries.filter(([, repo]) => repo.visibility === "private").length;
113
+ lines.push(` ✓ Source repos — ${repoEntries.length} configured (${privateCount} private SSH-gated)`);
114
+
108
115
  ctx.ui.notify(`Zendy Status:\n${lines.join("\n")}`, hasIssue ? "error" : "info");
109
116
  }
110
117
 
@@ -6,11 +6,45 @@ import { Type } from "typebox";
6
6
  import * as zendesk from "../dist/clients/zendesk.js";
7
7
  import * as helm from "../dist/clients/helm-watchdog.js";
8
8
  import * as kg from "../dist/clients/zendesk-kg.js";
9
+ import { getConfig } from "../dist/config/store.js";
9
10
 
11
+ // IMPORTANT: only `content` is sent to the model; `details` is UI/extension
12
+ // metadata the model never sees (verified empirically against pi 0.79). Any
13
+ // data the model must reason about has to be serialized into the text.
10
14
  function textResult(text: string, details: Record<string, unknown> = {}) {
11
15
  return { content: [{ type: "text" as const, text }], details };
12
16
  }
13
17
 
18
+ function fmtComment(c: zendesk.SlimComment, users: Map<number, string>): string {
19
+ const who = users.get(c.author_id) ?? `user:${c.author_id}`;
20
+ const vis = c.public ? "public" : "internal";
21
+ return `--- comment by ${who} (${vis}, ${c.created_at}) ---\n${c.plain_body ?? c.body ?? ""}`;
22
+ }
23
+
24
+ function fmtTicket(r: zendesk.TicketResult): string {
25
+ const t = r.ticket;
26
+ const users = new Map<number, string>();
27
+ if (r.requester) users.set(r.requester.id, `${r.requester.name} <${r.requester.email}> (requester)`);
28
+ if (r.assignee) users.set(r.assignee.id, `${r.assignee.name} <${r.assignee.email}> (assignee)`);
29
+ const customFields = (t.custom_fields ?? []).filter((f) => f.value !== null && f.value !== "");
30
+ const lines = [
31
+ `# Ticket #${t.id}: ${t.subject}`,
32
+ `status: ${t.status} | priority: ${t.priority ?? "-"} | created: ${t.created_at} | updated: ${t.updated_at}`,
33
+ `requester: ${r.requester ? `${r.requester.name} <${r.requester.email}>` : t.requester_id}`,
34
+ `assignee: ${r.assignee ? `${r.assignee.name} <${r.assignee.email}>` : t.assignee_id ?? "-"}`,
35
+ `tags: ${(t.tags ?? []).join(", ") || "-"}`,
36
+ customFields.length ? `custom_fields: ${JSON.stringify(customFields)}` : "",
37
+ "",
38
+ `## Description`,
39
+ t.description ?? "",
40
+ ].filter(Boolean);
41
+ if (r.comments?.length) {
42
+ lines.push("", `## Comments (${r.comments.length})`);
43
+ for (const c of r.comments) lines.push(fmtComment(c, users));
44
+ }
45
+ return lines.join("\n");
46
+ }
47
+
14
48
  function registerZendeskTools(pi: ExtensionAPI): void {
15
49
  pi.registerTool({
16
50
  name: "zendy_ticket_get",
@@ -26,15 +60,12 @@ function registerZendeskTools(pi: ExtensionAPI): void {
26
60
  }),
27
61
  async execute(_toolCallId: string, params: { ticketId: number }, signal?: AbortSignal) {
28
62
  const result = await zendesk.getTicketFull(params.ticketId, signal);
29
- return textResult(
30
- `Fetched Zendesk ticket #${result.ticket.id}: "${result.ticket.subject}" (${result.ticket.status}), ${result.comments?.length ?? 0} comments.`,
31
- {
32
- ticket: result.ticket,
33
- comments: result.comments,
34
- requester: result.requester,
35
- assignee: result.assignee,
36
- },
37
- );
63
+ return textResult(fmtTicket(result), {
64
+ ticket: result.ticket,
65
+ comments: result.comments,
66
+ requester: result.requester,
67
+ assignee: result.assignee,
68
+ });
38
69
  },
39
70
  });
40
71
 
@@ -113,31 +144,52 @@ function registerHelmTools(pi: ExtensionAPI): void {
113
144
  switch (params.resource) {
114
145
  case "version": {
115
146
  const data = await helm.getVersion(params.version!, signal);
116
- return textResult(`Helm Watchdog version metadata for ${params.version}.`, { version: params.version, data });
147
+ return textResult(
148
+ `Helm Watchdog version metadata for ${params.version}:\n${JSON.stringify(data, null, 2)}`,
149
+ { version: params.version, data },
150
+ );
117
151
  }
118
152
  case "values": {
119
153
  const data = await helm.getValues(params.version!, signal);
120
- return textResult(`Helm Watchdog values.yaml for ${params.version}.`, { version: params.version, data });
154
+ return textResult(
155
+ `values.yaml for chart ${params.version}:\n\n${data}`,
156
+ { version: params.version, data },
157
+ );
121
158
  }
122
159
  case "images": {
123
160
  const data = await helm.getImages(params.version!, params.validateImages, signal);
124
- return textResult(`Helm Watchdog images for ${params.version} (${data.length} images).`, { version: params.version, images: data });
161
+ return textResult(
162
+ `Images for chart ${params.version} (${data.length}):\n${JSON.stringify(data, null, 2)}`,
163
+ { version: params.version, images: data },
164
+ );
125
165
  }
126
166
  case "validation": {
127
167
  const data = await helm.getValidation(params.version!, params.status, signal);
128
- return textResult(`Helm Watchdog validation for ${params.version}.`, { version: params.version, results: data });
168
+ return textResult(
169
+ `Validation for chart ${params.version}:\n${JSON.stringify(data, null, 2)}`,
170
+ { version: params.version, results: data },
171
+ );
129
172
  }
130
173
  case "latest": {
131
174
  const data = await helm.getLatest(params.versionOnly, signal);
132
- return textResult("Helm Watchdog latest version.", { data });
175
+ return textResult(
176
+ `Latest chart version: ${typeof data === "string" ? data : JSON.stringify(data, null, 2)}`,
177
+ { data },
178
+ );
133
179
  }
134
180
  case "versions": {
135
181
  const data = await helm.listVersions(signal);
136
- return textResult(`Helm Watchdog cached versions (${data.length} entries).`, { versions: data });
182
+ return textResult(
183
+ `Cached chart versions (${data.length}):\n${JSON.stringify(data, null, 2)}`,
184
+ { versions: data },
185
+ );
137
186
  }
138
187
  case "cache": {
139
188
  const data = await helm.getCache(signal);
140
- return textResult("Helm Watchdog cache metadata.", { data });
189
+ return textResult(
190
+ `Cache metadata:\n${JSON.stringify(data, null, 2)}`,
191
+ { data },
192
+ );
141
193
  }
142
194
  }
143
195
  },
@@ -178,8 +230,15 @@ function registerKnowledgeGraphTools(pi: ExtensionAPI): void {
178
230
  ...(params.status ? { status: params.status } : {}),
179
231
  },
180
232
  }, signal);
233
+ const blocks = result.results.map((r, i) => [
234
+ `[${i + 1}] ticketId: ${r.ticketId} — ${r.subject}`,
235
+ ` status: ${r.status} | priority: ${r.priority} | created: ${r.createdAt} | versions: ${(r.versions ?? []).join(", ") || "-"} | rrfScore: ${r.rrfScore}`,
236
+ r.quickSummary ? ` quick: ${r.quickSummary}` : "",
237
+ r.issueSummary ? ` issue: ${r.issueSummary}` : "",
238
+ r.solutionSummary ? ` solution: ${r.solutionSummary}` : "",
239
+ ].filter(Boolean).join("\n"));
181
240
  return textResult(
182
- `KG search returned ${result.results.length} results for "${result.queryText}". Cite ticketId values from details.results.`,
241
+ `KG search returned ${result.results.length} results for "${result.queryText}". Cite ticketId values when summarizing.\n\n${blocks.join("\n\n")}`,
183
242
  { results: result.results, queryText: result.queryText },
184
243
  );
185
244
  },
@@ -190,14 +249,27 @@ function registerSourceTools(pi: ExtensionAPI): void {
190
249
  pi.registerTool({
191
250
  name: "zendy_source_status",
192
251
  label: "Zendy Source Status",
193
- description: "Report the zendy source-analysis workspace path and whether source cloning is authorized.",
194
- promptSnippet: "Check source workspace status with zendy_source_status.",
252
+ description: "Report the zendy source-analysis workspace path, bundled source repositories, and source-cloning authorization rules.",
253
+ promptSnippet: "Check source workspace and repo registry with zendy_source_status.",
195
254
  parameters: Type.Object({}),
196
255
  async execute() {
197
- return textResult("Source workspace status.", {
198
- workspace: process.env["ZENDY_SRC_DIR"] ?? null,
199
- note: "Source cloning/search requires explicit user permission per zendy workflow rules. Ask before cloning private source.",
256
+ const workspace = process.env["ZENDY_SRC_DIR"] ?? null;
257
+ const repos = getConfig().sourceRepos ?? {};
258
+ const repoLines = Object.entries(repos).map(([name, repo]) => {
259
+ const visibility = repo.visibility ?? "public";
260
+ const access = visibility === "private" ? "SSH access required" : "public";
261
+ return `- ${name}: ${repo.url} (${access})${repo.description ? ` — ${repo.description}` : ""}`;
200
262
  });
263
+ const note = "Source cloning/search requires explicit user permission per zendy workflow rules. Private Enterprise repos are SSH-gated; clone failures usually mean the user lacks GitHub access.";
264
+ return textResult(
265
+ [
266
+ `Source workspace: ${workspace ?? "(not active — created on session start)"}`,
267
+ "Configured source repositories:",
268
+ repoLines.length ? repoLines.join("\n") : "- (none configured)",
269
+ note,
270
+ ].join("\n"),
271
+ { workspace, repos, note },
272
+ );
201
273
  },
202
274
  });
203
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tplog/pi-zendy",
3
- "version": "0.3.8",
3
+ "version": "0.4.1",
4
4
  "description": "Pi package for Dify Enterprise support ticket analysis",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,9 +8,6 @@
8
8
  },
9
9
  "license": "MIT",
10
10
  "type": "module",
11
- "bin": {
12
- "zendy": "dist/index.js"
13
- },
14
11
  "keywords": [
15
12
  "pi-package",
16
13
  "pi",
@@ -25,7 +25,7 @@ support engineer can analyze a ticket end-to-end without leaving the terminal.
25
25
  | `zendy_whoami` | Verify which Zendesk account is authenticated |
26
26
  | `zendy_helm_get` | Dify Helm chart data by exact version: values.yaml, images, validation, latest |
27
27
  | `zendy_kg_search` | Semantic search over historical tickets; returns ticketIds |
28
- | `zendy_source_status` | Check the source-analysis workspace path and authorization rules |
28
+ | `zendy_source_status` | Check the source-analysis workspace, bundled repo registry, and authorization rules |
29
29
 
30
30
  Use these tools for all data access. Do not use zcli, curl, or any external Zendesk
31
31
  CLI. If credentials are missing, tell the user to run `/zendy-config`; check
@@ -55,6 +55,8 @@ connectivity with `/zendy-status`.
55
55
  - **Source code analysis requires explicit user permission.** If config-level
56
56
  analysis cannot settle the question, ask the user whether to check the source
57
57
  (e.g. "需要我去源码确认吗?") — never clone without permission. Use
58
- `zendy_source_status` to find the workspace.
58
+ `zendy_source_status` to find the workspace and bundled source repo URLs.
59
+ Enterprise backend/frontend repos are SSH-gated; if cloning fails, ask the user
60
+ to confirm their GitHub SSH access rather than trying alternate credentials.
59
61
  - **Inspect before filtering.** On the first call to an unfamiliar response shape,
60
62
  look at the structure before writing filter expressions; never assume field names.
@@ -1 +0,0 @@
1
- export declare function runCleanupCLI(argv: string[]): Promise<number>;
@@ -1,217 +0,0 @@
1
- // CLI entry for `zendy cleanup-src`.
2
- // Invoked from src/index.ts when the first user arg is "cleanup-src".
3
- import { createInterface } from "node:readline";
4
- import { scanCandidates, classifyCandidates, deleteCandidates, findOrphanSessionDirs, formatBytes, formatAge, } from "./source-cleanup.js";
5
- const USAGE = `
6
- Usage: zendy cleanup-src [options]
7
-
8
- Wipe Dify source clones and orphan zendy session dirs from /tmp.
9
-
10
- Defaults are aggressive by design: dify-enterprise is a private repo, and
11
- every extra minute of on-disk residency is an extra minute of exposure.
12
-
13
- Options:
14
- --dry-run Show what would be deleted; delete nothing.
15
- --yes, -y Skip the confirmation prompt in interactive mode.
16
- --days N Only delete dirs older than N days.
17
- (Without this flag, all matching dirs are deleted.)
18
- --all Delete all matching dirs regardless of age (default).
19
- --keep-last N Keep the N most-recent matching dirs. Default: 0.
20
- --min-age-minutes N Never delete dirs modified within last N minutes.
21
- Default: 5 (protects an actively-running clone).
22
- --pattern P1,P2 Comma-separated prefix patterns (end with *).
23
- Default: "dify-*,zendy-session-*".
24
- --dir PATH Base directory to scan. Default: /tmp.
25
- --no-sweep-orphans Skip the orphan-session sweep (orphan = session dir
26
- whose pid is no longer alive).
27
- -h, --help Show this help.
28
-
29
- Examples:
30
- # Preview what would be deleted right now:
31
- zendy cleanup-src --dry-run
32
-
33
- # Cron/launchd safe invocation (non-interactive, only older than 1 day):
34
- zendy cleanup-src --days 1 --yes
35
- `;
36
- function parseArgs(argv) {
37
- const opts = {
38
- dryRun: false,
39
- yes: false,
40
- all: true,
41
- keepLast: 0,
42
- minAgeMinutes: 5,
43
- patterns: ["dify-*", "zendy-session-*"],
44
- dir: "/tmp",
45
- sweepOrphans: true,
46
- };
47
- for (let i = 0; i < argv.length; i++) {
48
- const a = argv[i];
49
- switch (a) {
50
- case "-h":
51
- case "--help":
52
- return { kind: "help" };
53
- case "--dry-run":
54
- opts.dryRun = true;
55
- break;
56
- case "-y":
57
- case "--yes":
58
- opts.yes = true;
59
- break;
60
- case "--all":
61
- opts.all = true;
62
- opts.days = undefined;
63
- break;
64
- case "--no-sweep-orphans":
65
- opts.sweepOrphans = false;
66
- break;
67
- case "--days": {
68
- const v = argv[++i];
69
- const n = v !== undefined ? parseInt(v, 10) : NaN;
70
- if (Number.isNaN(n) || n < 0)
71
- return { kind: "error", message: `Invalid --days value: ${v}` };
72
- opts.days = n;
73
- opts.all = false;
74
- break;
75
- }
76
- case "--keep-last": {
77
- const v = argv[++i];
78
- const n = v !== undefined ? parseInt(v, 10) : NaN;
79
- if (Number.isNaN(n) || n < 0)
80
- return { kind: "error", message: `Invalid --keep-last value: ${v}` };
81
- opts.keepLast = n;
82
- break;
83
- }
84
- case "--min-age-minutes": {
85
- const v = argv[++i];
86
- const n = v !== undefined ? parseInt(v, 10) : NaN;
87
- if (Number.isNaN(n) || n < 0)
88
- return { kind: "error", message: `Invalid --min-age-minutes value: ${v}` };
89
- opts.minAgeMinutes = n;
90
- break;
91
- }
92
- case "--pattern": {
93
- const v = argv[++i];
94
- if (!v)
95
- return { kind: "error", message: "--pattern requires a value" };
96
- opts.patterns = v.split(",").map((s) => s.trim()).filter(Boolean);
97
- if (opts.patterns.length === 0)
98
- return { kind: "error", message: "--pattern list cannot be empty" };
99
- break;
100
- }
101
- case "--dir": {
102
- const v = argv[++i];
103
- if (!v)
104
- return { kind: "error", message: "--dir requires a path" };
105
- opts.dir = v;
106
- break;
107
- }
108
- default:
109
- return { kind: "error", message: `Unknown option: ${a}` };
110
- }
111
- }
112
- return { kind: "ok", opts };
113
- }
114
- function promptYesNo(question) {
115
- return new Promise((resolve) => {
116
- const rl = createInterface({ input: process.stdin, output: process.stderr });
117
- rl.question(question, (answer) => {
118
- rl.close();
119
- resolve(/^y(es)?$/i.test(answer.trim()));
120
- });
121
- });
122
- }
123
- export async function runCleanupCLI(argv) {
124
- const parsed = parseArgs(argv);
125
- if (parsed.kind === "help") {
126
- console.log(USAGE.trim());
127
- return 0;
128
- }
129
- if (parsed.kind === "error") {
130
- console.error(`Error: ${parsed.message}`);
131
- console.error(USAGE.trim());
132
- return 2;
133
- }
134
- const opts = parsed.opts;
135
- const nowMs = Date.now();
136
- const maxAgeMs = opts.all ? undefined : (opts.days ?? 0) * 86_400_000;
137
- const minAgeMs = opts.minAgeMinutes * 60_000;
138
- console.log(`Scanning ${opts.dir} for: ${opts.patterns.join(", ")}`);
139
- const candidates = await scanCandidates(opts.dir, opts.patterns);
140
- let orphanPaths = [];
141
- if (opts.sweepOrphans) {
142
- orphanPaths = await findOrphanSessionDirs(opts.dir);
143
- }
144
- const protectedPaths = [];
145
- const zendySrcDir = process.env["ZENDY_SRC_DIR"];
146
- if (zendySrcDir)
147
- protectedPaths.push(zendySrcDir);
148
- const classified = classifyCandidates(candidates, {
149
- nowMs,
150
- minAgeMs,
151
- maxAgeMs,
152
- keepLast: opts.keepLast,
153
- protectedPaths,
154
- });
155
- // Orphans are always eligible for deletion — their owning process is gone.
156
- // Merge them in, but never override a min-age or current-session protection.
157
- const toDeleteByPath = new Map();
158
- for (const c of classified.toDelete)
159
- toDeleteByPath.set(c.path, c);
160
- const protectedSet = new Set(classified.protected.map((c) => c.path));
161
- for (const pp of protectedPaths)
162
- protectedSet.add(pp);
163
- for (const orphanPath of orphanPaths) {
164
- if (protectedSet.has(orphanPath))
165
- continue;
166
- if (toDeleteByPath.has(orphanPath))
167
- continue;
168
- const cand = candidates.find((c) => c.path === orphanPath);
169
- if (cand)
170
- toDeleteByPath.set(cand.path, cand);
171
- }
172
- const toDelete = Array.from(toDeleteByPath.values());
173
- console.log(``);
174
- console.log(`Found ${candidates.length} matching director${candidates.length === 1 ? "y" : "ies"}:`);
175
- for (const c of [...candidates].sort((a, b) => b.mtimeMs - a.mtimeMs)) {
176
- const age = formatAge(nowMs - c.mtimeMs);
177
- const size = formatBytes(c.sizeBytes);
178
- let tag = "";
179
- if (protectedPaths.includes(c.path))
180
- tag = " [protected: current session]";
181
- else if (classified.protected.includes(c))
182
- tag = " [protected: within min-age]";
183
- else if (classified.kept.includes(c))
184
- tag = " [kept]";
185
- else if (toDeleteByPath.has(c.path)) {
186
- tag = orphanPaths.includes(c.path) ? " [delete: orphan session]" : " [delete]";
187
- }
188
- console.log(` ${c.path} ${age} ${size}${tag}`);
189
- }
190
- if (toDelete.length === 0) {
191
- console.log(``);
192
- console.log(`Nothing to delete.`);
193
- return 0;
194
- }
195
- const totalBytes = toDelete.reduce((a, b) => a + b.sizeBytes, 0);
196
- console.log(``);
197
- console.log(`Will delete ${toDelete.length} director${toDelete.length === 1 ? "y" : "ies"}, freeing ${formatBytes(totalBytes)}.`);
198
- if (opts.dryRun) {
199
- console.log(``);
200
- console.log(`[--dry-run] Nothing actually deleted. Re-run without --dry-run to delete.`);
201
- return 0;
202
- }
203
- if (!opts.yes && process.stdin.isTTY) {
204
- const ok = await promptYesNo(`Proceed? [y/N] `);
205
- if (!ok) {
206
- console.log(`Cancelled.`);
207
- return 0;
208
- }
209
- }
210
- const result = await deleteCandidates(toDelete);
211
- console.log(``);
212
- console.log(`Deleted ${result.deleted.length} director${result.deleted.length === 1 ? "y" : "ies"}, freed ${formatBytes(result.bytesFreed)}.`);
213
- for (const e of result.errors) {
214
- console.error(` ! ${e.path}: ${e.error}`);
215
- }
216
- return result.errors.length > 0 ? 1 : 0;
217
- }
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/index.js DELETED
@@ -1,115 +0,0 @@
1
- #!/usr/bin/env node
2
- import { join, dirname } from "node:path";
3
- import { execFileSync, spawn } from "node:child_process";
4
- import { fileURLToPath } from "node:url";
5
- import { createRequire } from "node:module";
6
- import { runPreflight, printPreflightReport, promptCoreMissing } from "./preflight.js";
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = dirname(__filename);
9
- const require = createRequire(import.meta.url);
10
- const pkg = require("../package.json");
11
- const VERSION = pkg.version;
12
- // Resolve package root (one level up from dist/)
13
- const PKG_ROOT = join(__dirname, "..");
14
- // The extension is loaded directly from the package's own extensions/ dir,
15
- // where zendy.ts and its sibling imports (tools.ts, commands.ts) live together.
16
- // This is the same file pi loads via the `pi.extensions` manifest field, so the
17
- // `zendy` launcher and `pi install` behave identically — no separate extraction step.
18
- const EXT_ZENDY = join(PKG_ROOT, "extensions", "zendy.ts");
19
- // Skill describing zendy and the ticket-analysis workflow. pi discovers it via
20
- // the pi.skills manifest field when installed as a package; the launcher passes
21
- // it explicitly so both load paths expose the same skill.
22
- const SKILL_ZENDY = join(PKG_ROOT, "skills", "zendy");
23
- function findPi() {
24
- try {
25
- return execFileSync("which", ["pi"], { encoding: "utf-8" }).trim();
26
- }
27
- catch {
28
- process.stderr.write("Error: `pi` not found in PATH.\n" +
29
- "Install it with: npm install -g @earendil-works/pi-coding-agent\n");
30
- process.exit(1);
31
- }
32
- }
33
- async function main() {
34
- const userArgs = process.argv.slice(2);
35
- // Intercept --version / -V / -v before passing to pi.
36
- // pi also recognises -v as a version flag, so without this interception
37
- // `zendy -v` would fall through and print pi's version, not zendy's.
38
- if (userArgs.includes("--version") ||
39
- userArgs.includes("-V") ||
40
- userArgs.includes("-v")) {
41
- console.log(`zendy ${VERSION}`);
42
- return;
43
- }
44
- // Intercept the cleanup-src subcommand — runs standalone, without pi.
45
- if (userArgs[0] === "cleanup-src") {
46
- const { runCleanupCLI } = await import("./cleanup-src.js");
47
- const code = await runCleanupCLI(userArgs.slice(1));
48
- process.exit(code);
49
- }
50
- // Intercept the preflight subcommand — runs the same checks as startup,
51
- // but on demand. `--json` emits machine-readable output (used by the
52
- // /status slash command extension).
53
- if (userArgs[0] === "preflight") {
54
- const wantJson = userArgs.includes("--json");
55
- const report = await runPreflight();
56
- if (wantJson) {
57
- process.stdout.write(JSON.stringify(report) + "\n");
58
- return;
59
- }
60
- printPreflightReport(report);
61
- if (report.results.every((r) => r.status === "ok")) {
62
- process.stdout.write("All checks passed.\n");
63
- }
64
- return;
65
- }
66
- // Skip preflight if user explicitly opts out
67
- if (!userArgs.includes("--skip-preflight")) {
68
- const report = await runPreflight();
69
- printPreflightReport(report);
70
- if (report.hasFatal) {
71
- process.exit(1);
72
- }
73
- if (report.hasCore) {
74
- // Only prompt interactively when stdin is a TTY;
75
- // in pipes / -p mode, just warn on stderr and continue.
76
- if (process.stdin.isTTY) {
77
- const shouldContinue = await promptCoreMissing();
78
- if (!shouldContinue) {
79
- process.exit(0);
80
- }
81
- }
82
- }
83
- }
84
- // Remove --skip-preflight before passing to pi
85
- const filteredArgs = userArgs.filter((a) => a !== "--skip-preflight");
86
- const pi = findPi();
87
- const args = [
88
- "--extension",
89
- EXT_ZENDY,
90
- "--skill",
91
- SKILL_ZENDY,
92
- ];
93
- // Pass through all user arguments
94
- args.push(...filteredArgs);
95
- // Spawn pi, replacing this process (inherit stdio for interactive use)
96
- const child = spawn(pi, args, {
97
- stdio: "inherit",
98
- });
99
- child.on("error", (err) => {
100
- process.stderr.write(`Failed to exec pi: ${err.message}\n`);
101
- process.exit(1);
102
- });
103
- child.on("exit", (code, signal) => {
104
- if (signal) {
105
- process.kill(process.pid, signal);
106
- }
107
- else {
108
- process.exit(code ?? 1);
109
- }
110
- });
111
- }
112
- main().catch((err) => {
113
- process.stderr.write(`zendy: ${err.message}\n`);
114
- process.exit(1);
115
- });
@@ -1,22 +0,0 @@
1
- export type CheckLevel = "fatal" | "core" | "enhanced";
2
- export type CheckStatus = "ok" | "missing" | "auth_error";
3
- export interface CheckResult {
4
- name: string;
5
- label: string;
6
- level: CheckLevel;
7
- status: CheckStatus;
8
- hint: string;
9
- }
10
- export interface PreflightReport {
11
- results: CheckResult[];
12
- hasFatal: boolean;
13
- hasCore: boolean;
14
- hasEnhanced: boolean;
15
- }
16
- export declare function runPreflight(): Promise<PreflightReport>;
17
- export declare function printPreflightReport(report: PreflightReport): void;
18
- /**
19
- * Interactive prompt for core dependency failures.
20
- * Returns true if the user wants to continue, false to exit.
21
- */
22
- export declare function promptCoreMissing(): Promise<boolean>;
package/dist/preflight.js DELETED
@@ -1,264 +0,0 @@
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
- import { resolveZendeskConfig, resolveKgConfig } from "./config/resolve.js";
7
- import { DEFAULT_KG_API_URL } from "./config/schema.js";
8
- // ── Helpers ────────────────────────────────────────────────────────────
9
- function exec(cmd, args, timeoutMs = 5000) {
10
- return new Promise((resolve) => {
11
- execFile(cmd, args, { timeout: timeoutMs, encoding: "utf-8" }, (err, stdout, stderr) => {
12
- if (err && err.code === "ENOENT") {
13
- resolve({ code: 127, stdout: "", stderr: "" });
14
- return;
15
- }
16
- // execFile callback err has an `exit code` when the process exits non-zero
17
- const exitCode = err ? (err.status ?? 1) : 0;
18
- resolve({ code: exitCode, stdout: stdout ?? "", stderr: stderr ?? "" });
19
- });
20
- });
21
- }
22
- // ── Individual checks ──────────────────────────────────────────────────
23
- // Common API key env vars that pi recognizes
24
- const PI_API_KEY_VARS = [
25
- "ANTHROPIC_API_KEY",
26
- "ANTHROPIC_OAUTH_TOKEN",
27
- "OPENAI_API_KEY",
28
- "GEMINI_API_KEY",
29
- "GROQ_API_KEY",
30
- "OPENROUTER_API_KEY",
31
- "XAI_API_KEY",
32
- "MISTRAL_API_KEY",
33
- "AWS_ACCESS_KEY_ID",
34
- ];
35
- async function checkPi() {
36
- const base = {
37
- name: "pi",
38
- label: "AI Agent (pi)",
39
- level: "fatal",
40
- };
41
- const result = await exec("pi", ["--version"]);
42
- if (result.code === 127) {
43
- return {
44
- ...base,
45
- status: "missing",
46
- hint: "Install pi: npm install -g @earendil-works/pi-coding-agent",
47
- };
48
- }
49
- // pi is installed — check if any auth is configured.
50
- // Auth can be in ~/.pi/agent/auth.json (OAuth login) or via API key env vars.
51
- const authFile = join(homedir(), ".pi", "agent", "auth.json");
52
- const hasFileAuth = existsSync(authFile);
53
- const hasEnvAuth = PI_API_KEY_VARS.some((v) => !!process.env[v]);
54
- if (!hasFileAuth && !hasEnvAuth) {
55
- return {
56
- ...base,
57
- status: "auth_error",
58
- hint: "pi is installed but no provider is configured.\nRun: pi (to log in interactively)\nOr set an API key: export GEMINI_API_KEY=...",
59
- };
60
- }
61
- return { ...base, status: "ok", hint: "" };
62
- }
63
- async function checkZendesk() {
64
- const base = {
65
- name: "zendesk",
66
- label: "Zendesk access",
67
- level: "core",
68
- };
69
- const cfg = resolveZendeskConfig();
70
- if (!cfg) {
71
- return {
72
- ...base,
73
- status: "missing",
74
- hint: "Zendesk credentials not configured.\nUse /zendy-config in pi, or set env: ZENDY_ZENDESK_SUBDOMAIN, ZENDY_ZENDESK_EMAIL, ZENDY_ZENDESK_API_TOKEN",
75
- };
76
- }
77
- try {
78
- const ctrl = new AbortController();
79
- const timeout = setTimeout(() => ctrl.abort(), 10000);
80
- const auth = "Basic " + Buffer.from(`${cfg.email}/token:${cfg.apiToken}`).toString("base64");
81
- const response = await fetch(`https://${cfg.subdomain}.zendesk.com/api/v2/users/me.json`, {
82
- headers: { Authorization: auth, Accept: "application/json", "User-Agent": "zendy-preflight" },
83
- signal: ctrl.signal,
84
- });
85
- clearTimeout(timeout);
86
- if (response.ok) {
87
- const data = await response.json();
88
- return { ...base, status: "ok", hint: `Authenticated as ${data.user?.email ?? "unknown"}` };
89
- }
90
- return {
91
- ...base,
92
- status: "auth_error",
93
- hint: `Zendesk API returned ${response.status}. Check credentials in ~/.zendy/config.json or env vars.`,
94
- };
95
- }
96
- catch (e) {
97
- const msg = e.message || String(e);
98
- return {
99
- ...base,
100
- status: "auth_error",
101
- hint: `Zendesk API unreachable: ${msg.slice(0, 200)}`,
102
- };
103
- }
104
- }
105
- async function checkZendeskKgApi() {
106
- const base = {
107
- name: "zendesk-kg",
108
- label: "Zendesk Knowledge Graph",
109
- level: "enhanced",
110
- };
111
- const cfg = resolveKgConfig();
112
- if (!cfg) {
113
- return {
114
- ...base,
115
- status: "missing",
116
- hint: "Knowledge Graph API key not configured.\nUse /zendy-config in pi, or set env: ZENDY_KG_API_KEY",
117
- };
118
- }
119
- try {
120
- const ctrl = new AbortController();
121
- const timeout = setTimeout(() => ctrl.abort(), 10000);
122
- const baseUrl = cfg.apiUrl || DEFAULT_KG_API_URL;
123
- const headers = {
124
- Accept: "application/json",
125
- Authorization: `Bearer ${cfg.apiKey}`,
126
- "User-Agent": "zendy-preflight",
127
- };
128
- if (cfg.apiKey)
129
- headers["x-api-key"] = cfg.apiKey;
130
- const response = await fetch(`${baseUrl}/health`, { headers, signal: ctrl.signal });
131
- clearTimeout(timeout);
132
- if (response.ok) {
133
- const data = await response.json();
134
- return { ...base, status: "ok", hint: `Status: ${data.status ?? "ok"}` };
135
- }
136
- return {
137
- ...base,
138
- status: "auth_error",
139
- hint: `KG API returned ${response.status}. Check credentials.`,
140
- };
141
- }
142
- catch (e) {
143
- const msg = e.message || String(e);
144
- return {
145
- ...base,
146
- status: "auth_error",
147
- hint: `KG API unreachable: ${msg.slice(0, 200)}`,
148
- };
149
- }
150
- }
151
- async function checkGithub() {
152
- const base = {
153
- name: "github",
154
- label: "GitHub access",
155
- level: "enhanced",
156
- };
157
- const result = await exec("ssh", ["-T", "-o", "ConnectTimeout=3", "-o", "StrictHostKeyChecking=no", "git@github.com"]);
158
- // ssh -T git@github.com exits 1 on success (GitHub prints "Hi user!")
159
- // exits 255 on auth failure, and 127 if ssh missing
160
- if (result.code === 127) {
161
- return {
162
- ...base,
163
- status: "missing",
164
- hint: "SSH client not found. Install OpenSSH to enable source code analysis.",
165
- };
166
- }
167
- const combined = result.stdout + result.stderr;
168
- if (combined.includes("successfully authenticated") || combined.includes("Hi ")) {
169
- return { ...base, status: "ok", hint: "" };
170
- }
171
- return {
172
- ...base,
173
- status: "auth_error",
174
- 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",
175
- };
176
- }
177
- // ── Preflight runner ───────────────────────────────────────────────────
178
- export async function runPreflight() {
179
- const results = await Promise.all([checkPi(), checkZendesk(), checkZendeskKgApi(), checkGithub()]);
180
- const failed = results.filter((r) => r.status !== "ok");
181
- return {
182
- results,
183
- hasFatal: failed.some((r) => r.level === "fatal"),
184
- hasCore: failed.some((r) => r.level === "core"),
185
- hasEnhanced: failed.some((r) => r.level === "enhanced"),
186
- };
187
- }
188
- // ── Terminal display ───────────────────────────────────────────────────
189
- const RESET = "\x1b[0m";
190
- const BOLD = "\x1b[1m";
191
- const RED = "\x1b[31m";
192
- const YELLOW = "\x1b[33m";
193
- const GREEN = "\x1b[32m";
194
- const DIM = "\x1b[2m";
195
- function statusIcon(status) {
196
- if (status === "ok")
197
- return `${GREEN}✓${RESET}`;
198
- return `${RED}✗${RESET}`;
199
- }
200
- export function printPreflightReport(report) {
201
- const failed = report.results.filter((r) => r.status !== "ok");
202
- if (failed.length === 0)
203
- return; // all good, stay silent
204
- console.error("");
205
- console.error(`${BOLD}Zendy setup check${RESET}`);
206
- console.error("");
207
- for (const r of report.results) {
208
- console.error(` ${statusIcon(r.status)} ${r.label}`);
209
- }
210
- console.error("");
211
- // Fatal — hard stop
212
- if (report.hasFatal) {
213
- const fatal = failed.filter((r) => r.level === "fatal");
214
- for (const r of fatal) {
215
- console.error(`${RED}${BOLD}Error:${RESET} ${r.label} is required but not available.`);
216
- console.error(`${DIM}${r.hint}${RESET}`);
217
- console.error("");
218
- }
219
- return;
220
- }
221
- // Core missing — strong warning with options
222
- if (report.hasCore) {
223
- const core = failed.filter((r) => r.level === "core");
224
- for (const r of core) {
225
- console.error(`${YELLOW}${BOLD}Warning:${RESET} ${r.label} is not configured.`);
226
- console.error(`${DIM}${r.hint}${RESET}`);
227
- console.error("");
228
- }
229
- }
230
- // Enhanced missing — light banner
231
- if (report.hasEnhanced) {
232
- const enhanced = failed.filter((r) => r.level === "enhanced");
233
- for (const r of enhanced) {
234
- console.error(`${DIM}Note: ${r.label} is not configured. ${r.hint.split("\n")[0]}${RESET}`);
235
- }
236
- console.error("");
237
- }
238
- }
239
- /**
240
- * Interactive prompt for core dependency failures.
241
- * Returns true if the user wants to continue, false to exit.
242
- */
243
- export function promptCoreMissing() {
244
- return new Promise((resolve) => {
245
- const rl = createInterface({
246
- input: process.stdin,
247
- output: process.stderr,
248
- });
249
- console.error(`${BOLD}What would you like to do?${RESET}`);
250
- console.error(` ${BOLD}1${RESET} Continue with limited capabilities`);
251
- console.error(` ${BOLD}2${RESET} Exit and configure manually`);
252
- console.error("");
253
- rl.question(`${DIM}Choose [1/2]: ${RESET}`, (answer) => {
254
- rl.close();
255
- const choice = answer.trim();
256
- if (choice === "2") {
257
- resolve(false);
258
- }
259
- else {
260
- resolve(true); // default: continue
261
- }
262
- });
263
- });
264
- }
@@ -1,43 +0,0 @@
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;
@@ -1,188 +0,0 @@
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/zendy.ts` — the pi extension inlines the tiny session-dir
10
- // helpers it needs so it can be loaded as a self-contained TS file by pi
11
- // 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
- }