@tplog/pi-zendy 0.2.17 → 0.3.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 +51 -22
- package/dist/clients/helm-watchdog.d.ts +7 -0
- package/dist/clients/helm-watchdog.js +49 -0
- package/dist/clients/zendesk-kg.d.ts +59 -0
- package/dist/clients/zendesk-kg.js +100 -0
- package/dist/clients/zendesk.d.ts +64 -0
- package/dist/clients/zendesk.js +90 -0
- package/dist/config/migrate.d.ts +6 -0
- package/dist/config/migrate.js +78 -0
- package/dist/config/schema.d.ts +14 -0
- package/dist/config/schema.js +2 -0
- package/dist/config/store.d.ts +7 -0
- package/dist/config/store.js +81 -0
- package/dist/index.js +4 -44
- package/dist/preflight.js +101 -24
- package/extensions/commands.ts +119 -0
- package/extensions/tools.ts +190 -0
- package/extensions/zendy.ts +154 -0
- package/package.json +3 -9
- package/agents.md +0 -118
- package/extensions/custom-header.ts +0 -49
- package/extensions/source-cleanup.ts +0 -162
- package/extensions/status.ts +0 -82
- package/extensions/zendy-context.ts +0 -24
- package/skills/helm-watchdog/SKILL.md +0 -146
- package/skills/source-check/SKILL.md +0 -143
- package/skills/zendesk-cli/SKILL.md +0 -37
- package/skills/zendesk-kg/SKILL.md +0 -120
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zendy — unified pi extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Tools (LLM-callable): zendy_ticket_get, zendy_ticket_search,
|
|
6
|
+
* zendy_helm_get, zendy_kg_search, zendy_source_status
|
|
7
|
+
* - Slash commands: /zendy-config, /zendy-status
|
|
8
|
+
* - Session lifecycle: workspace dir + cleanup + orphan sweep
|
|
9
|
+
* - Custom header on session start
|
|
10
|
+
*
|
|
11
|
+
* No external CLI dependencies (zcli, zendesk-kg) required.
|
|
12
|
+
* All data access is through direct REST APIs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { registerAllTools } from "./tools.js";
|
|
17
|
+
import { registerAllCommands } from "./commands.js";
|
|
18
|
+
import { mkdirSync, rmSync, readdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const BASE_DIR = "/tmp";
|
|
24
|
+
const SESSION_PREFIX = "zendy-session-";
|
|
25
|
+
const WIPE_PREFIXES = ["dify-", SESSION_PREFIX];
|
|
26
|
+
|
|
27
|
+
// ── Session workspace helpers ──────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function parseSessionDir(name: string): { pid: number } | null {
|
|
30
|
+
const m = /^zendy-session-(\d+)-(\d+)$/.exec(name);
|
|
31
|
+
return m ? { pid: parseInt(m[1]!, 10) } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isProcessAlive(pid: number): boolean {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeRmrf(path: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
rmSync(path, { recursive: true, force: true });
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sweepOrphans(base: string, exclude: string): string[] {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(base, { withFileTypes: true });
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const removed: string[] = [];
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (!e.isDirectory()) continue;
|
|
62
|
+
const info = parseSessionDir(e.name);
|
|
63
|
+
if (!info) continue;
|
|
64
|
+
const full = join(base, e.name);
|
|
65
|
+
if (full === exclude) continue;
|
|
66
|
+
if (isProcessAlive(info.pid)) continue;
|
|
67
|
+
if (safeRmrf(full)) removed.push(full);
|
|
68
|
+
}
|
|
69
|
+
return removed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── ASCII header ───────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const HEADER = `
|
|
75
|
+
███████╗███████╗███╗ ██╗██████╗ ██╗ ██╗
|
|
76
|
+
╚══███╔╝██╔════╝████╗ ██║██╔══██╗╚██╗ ██╔╝
|
|
77
|
+
███╔╝ █████╗ ██╔██╗ ██║██║ ██║ ╚████╔╝
|
|
78
|
+
███╔╝ ██╔══╝ ██║╚██╗██║██║ ██║ ╚██╔╝
|
|
79
|
+
███████╗███████╗██║ ╚████║██████╔╝ ██║
|
|
80
|
+
╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝ ╚═╝
|
|
81
|
+
Dify Enterprise Support Harness
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
// ── Extension entry ────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export default function (pi: ExtensionAPI) {
|
|
87
|
+
// ── Tools & Commands ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
registerAllTools(pi);
|
|
90
|
+
registerAllCommands(pi);
|
|
91
|
+
|
|
92
|
+
// ── Session lifecycle ───────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const ts = Date.now();
|
|
95
|
+
const sessionDir = join(BASE_DIR, `${SESSION_PREFIX}${process.pid}-${ts}`);
|
|
96
|
+
let cleanedUp = false;
|
|
97
|
+
|
|
98
|
+
function ensureDir(): void {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
101
|
+
} catch {
|
|
102
|
+
// non-fatal
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cleanupSession(): void {
|
|
107
|
+
if (cleanedUp) return;
|
|
108
|
+
cleanedUp = true;
|
|
109
|
+
safeRmrf(sessionDir);
|
|
110
|
+
|
|
111
|
+
// Do not rely on the human remembering manual cleanup: wipe standalone
|
|
112
|
+
// source clones too when pi exits. The current session dir was already
|
|
113
|
+
// removed above; this covers clones created directly under /tmp/dify-*.
|
|
114
|
+
try {
|
|
115
|
+
const entries = readdirSync(BASE_DIR, { withFileTypes: true });
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
if (!e.isDirectory()) continue;
|
|
118
|
+
if (!WIPE_PREFIXES.some((p) => e.name.startsWith(p))) continue;
|
|
119
|
+
safeRmrf(join(BASE_DIR, e.name));
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// non-fatal
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
process.on("exit", cleanupSession);
|
|
127
|
+
process.on("SIGINT", () => { cleanupSession(); process.exit(130); });
|
|
128
|
+
process.on("SIGTERM", () => { cleanupSession(); process.exit(143); });
|
|
129
|
+
process.on("uncaughtException", (err) => {
|
|
130
|
+
cleanupSession();
|
|
131
|
+
console.error(err);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
136
|
+
ensureDir();
|
|
137
|
+
process.env["ZENDY_SRC_DIR"] = sessionDir;
|
|
138
|
+
|
|
139
|
+
// Custom header
|
|
140
|
+
if (ctx.hasUI) {
|
|
141
|
+
ctx.ui.notify(HEADER, "info");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sweep orphan session dirs
|
|
145
|
+
const removed = sweepOrphans(BASE_DIR, sessionDir);
|
|
146
|
+
if (ctx.hasUI && removed.length > 0) {
|
|
147
|
+
ctx.ui.notify(`[zendy] swept ${removed.length} orphan session dir(s)`, "info");
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_shutdown", async () => {
|
|
152
|
+
cleanupSession();
|
|
153
|
+
});
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tplog/pi-zendy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Pi package for Dify Enterprise support ticket analysis",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,16 +18,11 @@
|
|
|
18
18
|
],
|
|
19
19
|
"pi": {
|
|
20
20
|
"extensions": [
|
|
21
|
-
"./extensions"
|
|
22
|
-
],
|
|
23
|
-
"skills": [
|
|
24
|
-
"./skills"
|
|
21
|
+
"./extensions/zendy.ts"
|
|
25
22
|
]
|
|
26
23
|
},
|
|
27
24
|
"files": [
|
|
28
25
|
"dist",
|
|
29
|
-
"agents.md",
|
|
30
|
-
"skills",
|
|
31
26
|
"extensions"
|
|
32
27
|
],
|
|
33
28
|
"publishConfig": {
|
|
@@ -37,8 +32,7 @@
|
|
|
37
32
|
"build": "tsc",
|
|
38
33
|
"test": "tsc && node --test test/*.test.mjs",
|
|
39
34
|
"prepare": "tsc",
|
|
40
|
-
"prepublishOnly": "tsc"
|
|
41
|
-
"postinstall": "command -v zendesk-kg >/dev/null 2>&1 || (curl -fsSL https://raw.githubusercontent.com/sorphwer/zendesk-kg-cli-release/main/install.sh | sh || true)"
|
|
35
|
+
"prepublishOnly": "tsc"
|
|
42
36
|
},
|
|
43
37
|
"peerDependencies": {
|
|
44
38
|
"@earendil-works/pi-coding-agent": "*"
|
package/agents.md
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
# zendy
|
|
2
|
-
|
|
3
|
-
Internal support engineering harness for Dify Enterprise. Powered by pi.
|
|
4
|
-
|
|
5
|
-
## Identity
|
|
6
|
-
|
|
7
|
-
zendy is a Pi package that loads a fixed set of skills + extensions on top of [pi](https://pi.dev) so a support engineer can analyze a Zendesk ticket end-to-end without leaving the terminal. The published npm package is `@tplog/pi-zendy`; the source is the private repo `tplog/pi-zendy`.
|
|
8
|
-
|
|
9
|
-
## What zendy ships with
|
|
10
|
-
|
|
11
|
-
Three skills, loaded by default:
|
|
12
|
-
|
|
13
|
-
- `zendesk-cli` — Pull Zendesk ticket metadata and comment threads via `zcli`
|
|
14
|
-
- `helm-watchdog` — Query Dify Helm chart values, images, and validation by version
|
|
15
|
-
- `source-check` — Clone and analyze Dify source code (only when the user explicitly asks)
|
|
16
|
-
|
|
17
|
-
One additional opt-in skill:
|
|
18
|
-
|
|
19
|
-
- `zendesk-kg` — Hybrid retrieval over a Zendesk Knowledge Graph for cross-ticket / similar-issue lookups
|
|
20
|
-
|
|
21
|
-
Two extensions:
|
|
22
|
-
|
|
23
|
-
- `custom-header` — ZENDY ASCII art on session start
|
|
24
|
-
- `source-cleanup` — Per-session `/tmp/zendy-session-<pid>-<ts>/` directory + automatic wipe on exit; orphan sweep on next start
|
|
25
|
-
|
|
26
|
-
One slash command beyond what pi gives you: `/status` runs `zendy preflight --json` and renders a per-source connectivity check (pi auth / Zendesk / GitHub / zendesk-kg).
|
|
27
|
-
|
|
28
|
-
## Typical workflow
|
|
29
|
-
|
|
30
|
-
1. User gives a ticket number → use `zcli` to pull ticket + comments
|
|
31
|
-
2. Identify the customer's Dify version from ticket fields
|
|
32
|
-
3. Pull the corresponding Helm chart values via the helm-watchdog API
|
|
33
|
-
4. If the issue requires code-level analysis, the user will say so → use the `source-check` skill
|
|
34
|
-
5. (optional) Use `zendesk-kg search` to find similar past tickets if cross-ticket evidence helps
|
|
35
|
-
6. Synthesize findings and draft a reply in the ticket's language
|
|
36
|
-
|
|
37
|
-
## Behavior rules
|
|
38
|
-
|
|
39
|
-
- **Never guess.** Base conclusions on actual data — ticket fields, values.yaml, source code, ticket-graph results. State clearly what is fact vs inference.
|
|
40
|
-
- **Don't recommend solutions you haven't verified.** If you haven't confirmed something in the code or docs, say so.
|
|
41
|
-
- **Version matters.** Always check the customer's exact Dify version — defaults and behavior change between releases.
|
|
42
|
-
- **Reply drafts match the ticket language.** Detect the language from the ticket thread (Japanese, Chinese, or English) and draft in the same language with appropriate business tone.
|
|
43
|
-
- **Suggest source-check when confidence is low.** If your analysis cannot be confirmed from config alone, proactively ask the user: "需要我去源码确认吗?" Don't clone without permission, but suggest it instead of leaving uncertainty unresolved.
|
|
44
|
-
- **Inspect before filtering.** When calling an unfamiliar API for the first time, look at the response structure (`| jq 'type'`, `| jq '.[0] | keys'`, or `| head`) before writing filter expressions. Never assume field names.
|
|
45
|
-
- **Skills are APIs, not CLIs (mostly).** `helm-watchdog` is an HTTP API (use `curl`); `zcli` is a CLI; `zendesk-kg` is a CLI. Read the skill's Quick Reference before your first call. Do not guess command names.
|
|
46
|
-
- **Auto-install missing dependencies.** If `zcli` or `zendesk-kg` is not found, run their installer automatically and continue. Don't ask — this is an internal tool, all dependencies are expected. (`zendy` v0.2.13+ also auto-installs `zendesk-kg` via postinstall.)
|
|
47
|
-
- **Cite sources when you summarize.** When using `zendesk-kg search` results, surface the original `ticketId`s — the engineer wants to read the source ticket, not just your synthesis.
|
|
48
|
-
- **`zendesk-kg health` is currently unreliable.** The retriever's `/health` endpoint sits behind a 302 → login page even though `/search` is API-key-authenticated and works. Don't gate retries on `health`. Only `health: ok` is a real signal; `health: unreachable` is inconclusive.
|
|
49
|
-
|
|
50
|
-
## Development flow
|
|
51
|
-
|
|
52
|
-
The development of zendy itself follows three rules. These are non-negotiable.
|
|
53
|
-
|
|
54
|
-
1. **`main` is always the latest source of truth.** All accepted changes land on `main`; no long-lived divergent branches.
|
|
55
|
-
2. **Tags drive releases.** Pushing a `vX.Y.Z` tag triggers `.github/workflows/publish.yml`, which verifies `tag == package.json.version`, runs tests, and `npm publish --access public`. The npm registry is downstream of the tag, never the other way around.
|
|
56
|
-
3. **Every push to `main` goes through a Pull Request.** No direct pushes to `main`, no exceptions. This includes `chore: release X.Y.Z` version-bump commits — they too open a branch + PR before being merged.
|
|
57
|
-
|
|
58
|
-
### Concrete steps for a code change
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
1. Create a branch: git checkout -b <type>/<short-name>
|
|
62
|
-
2. Make the change locally
|
|
63
|
-
3. Verify locally: npm test (or relevant manual checks)
|
|
64
|
-
4. Commit + push the branch: git push -u origin <branch>
|
|
65
|
-
5. Open a PR: gh pr create --title "..." --body "..."
|
|
66
|
-
6. Wait for the human owner's review/merge
|
|
67
|
-
7. After merge, locally: git checkout main && git pull
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Do not bump `package.json.version` in the same PR as a feature/fix unless the human owner asked for it. Version bumps belong in their own PR.
|
|
71
|
-
|
|
72
|
-
### Concrete steps for a release
|
|
73
|
-
|
|
74
|
-
```
|
|
75
|
-
1. main has the merged feature/fix commits you want to ship
|
|
76
|
-
2. Branch: git checkout -b chore/release-X.Y.Z
|
|
77
|
-
3. Bump: edit package.json (version)
|
|
78
|
-
npm install --package-lock-only
|
|
79
|
-
4. Commit + push branch: git push -u origin chore/release-X.Y.Z
|
|
80
|
-
5. Open the release PR: gh pr create --title "chore: release X.Y.Z" ...
|
|
81
|
-
6. Wait for merge
|
|
82
|
-
7. After merge, on local main: git checkout main && git pull
|
|
83
|
-
git tag vX.Y.Z
|
|
84
|
-
git push origin vX.Y.Z
|
|
85
|
-
8. Watch the workflow: gh run watch <id> --exit-status
|
|
86
|
-
9. Verify on npm: npm view @tplog/pi-zendy version
|
|
87
|
-
10. Cut a GitHub Release: gh release create vX.Y.Z --title "..." --notes "..."
|
|
88
|
-
11. Announce in #zendy with the GH Release URL.
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Release timing (whether to tag now vs accumulate more PRs first) is a **human decision**. Agents should not unilaterally tag — ask the human owner before step 7.
|
|
92
|
-
|
|
93
|
-
### Test machine handoff
|
|
94
|
-
|
|
95
|
-
The local sandbox where an agent develops is **not** the same as the human's test machine. To let the human verify a not-yet-merged change without going through GitHub auth:
|
|
96
|
-
|
|
97
|
-
```
|
|
98
|
-
1. On the agent machine, after `npm test` passes locally:
|
|
99
|
-
npm pack
|
|
100
|
-
slock attachment upload --path tplog-zendy-<version>.tgz --channel "#zendy"
|
|
101
|
-
2. Tell the human: `npm install -g <downloaded-tarball-path>`
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
This bypasses `npm install -g git+ssh://...#main`, which currently fails because npm 10/11 doesn't install devDependencies during git-dep `prepare`.
|
|
105
|
-
|
|
106
|
-
## Release pitfalls — do not repeat
|
|
107
|
-
|
|
108
|
-
- **`--provenance` does NOT work for this package.** npm rejects provenance attestations for private source repos ("Only public source repositories are supported when publishing with provenance"). The workflow intentionally omits `--provenance` and `id-token: write`. Do not add them back.
|
|
109
|
-
- **Tags are immutable.** If a publish fails, bump the version and push a new tag — never delete and re-push the same tag. Gaps in the npm version sequence are fine.
|
|
110
|
-
- **Tag version must equal `package.json.version`.** The workflow fails fast if they disagree.
|
|
111
|
-
- **Do not run `npm publish` locally.** The workflow is the canonical path.
|
|
112
|
-
- **Never push `chore: release X.Y.Z` directly to main.** It still goes through a PR (rule 3).
|
|
113
|
-
|
|
114
|
-
## Security note for contributors
|
|
115
|
-
|
|
116
|
-
This package is published to npm as-is. Anything written into `skills/**`, `extensions/**`, or `agents.md` becomes public once published. **Never** embed tokens, API keys, passwords, internal-only URLs, customer names, or any non-public information in these files. Runtime configuration (credentials, per-user endpoints) must come from environment variables or user config — never hard-coded.
|
|
117
|
-
|
|
118
|
-
The source repo `tplog/pi-zendy` is private by design. The `files` whitelist in `package.json` controls what ships to the public npm tarball. Do not propose making the repo public.
|
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
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
|
-
}
|
package/extensions/status.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Status slash-command extension.
|
|
3
|
-
*
|
|
4
|
-
* Registers `/status` inside pi. On invocation it shells out to
|
|
5
|
-
* `zendy preflight --json` and renders the result as a single notification.
|
|
6
|
-
*
|
|
7
|
-
* Why subprocess instead of duplicating check logic here? The canonical
|
|
8
|
-
* checks live in `src/preflight.ts` (pi/zcli/github), and the npm-published
|
|
9
|
-
* `zendy` binary is in PATH (pi was launched by it). One source of truth,
|
|
10
|
-
* trivial extension, ~50ms cold start per invocation — fine for an
|
|
11
|
-
* interactive on-demand command.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
-
import { execFile } from "node:child_process";
|
|
16
|
-
|
|
17
|
-
interface CheckResult {
|
|
18
|
-
name: string;
|
|
19
|
-
label: string;
|
|
20
|
-
level: "fatal" | "core" | "enhanced";
|
|
21
|
-
status: "ok" | "missing" | "auth_error";
|
|
22
|
-
hint: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface PreflightReport {
|
|
26
|
-
results: CheckResult[];
|
|
27
|
-
hasFatal: boolean;
|
|
28
|
-
hasCore: boolean;
|
|
29
|
-
hasEnhanced: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function runZendyPreflight(): Promise<
|
|
33
|
-
{ ok: true; report: PreflightReport } | { ok: false; error: string }
|
|
34
|
-
> {
|
|
35
|
-
return new Promise((resolve) => {
|
|
36
|
-
execFile(
|
|
37
|
-
"zendy",
|
|
38
|
-
["preflight", "--json"],
|
|
39
|
-
{ timeout: 15000, encoding: "utf-8" },
|
|
40
|
-
(err, stdout, stderr) => {
|
|
41
|
-
if (err && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
42
|
-
resolve({ ok: false, error: "`zendy` not found in PATH" });
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (err) {
|
|
46
|
-
const msg = stderr.trim() || (err as Error).message;
|
|
47
|
-
resolve({ ok: false, error: msg });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
const report = JSON.parse(stdout.trim()) as PreflightReport;
|
|
52
|
-
resolve({ ok: true, report });
|
|
53
|
-
} catch {
|
|
54
|
-
resolve({ ok: false, error: `could not parse preflight JSON: ${stdout.slice(0, 200)}` });
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function formatLine(r: CheckResult): string {
|
|
62
|
-
if (r.status === "ok") return ` ✓ ${r.label}`;
|
|
63
|
-
const firstHintLine = r.hint.split("\n")[0] ?? "";
|
|
64
|
-
return ` ✗ ${r.label}${firstHintLine ? ` — ${firstHintLine}` : ""}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export default function (pi: ExtensionAPI) {
|
|
68
|
-
pi.registerCommand("status", {
|
|
69
|
-
description: "Show pi / Zendesk / GitHub connection status",
|
|
70
|
-
handler: async (_args, ctx) => {
|
|
71
|
-
const result = await runZendyPreflight();
|
|
72
|
-
if (!result.ok) {
|
|
73
|
-
ctx.ui.notify(`[status] could not run preflight: ${result.error}`, "error");
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const { report } = result;
|
|
77
|
-
const body = report.results.map(formatLine).join("\n");
|
|
78
|
-
const hasIssue = report.hasFatal || report.hasCore || report.hasEnhanced;
|
|
79
|
-
ctx.ui.notify(`zendy status:\n${body}`, hasIssue ? "error" : "info");
|
|
80
|
-
},
|
|
81
|
-
});
|
|
82
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
|
|
6
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const AGENTS_PATH = join(__dirname, "..", "agents.md");
|
|
8
|
-
|
|
9
|
-
let cachedContext: string | undefined;
|
|
10
|
-
|
|
11
|
-
function loadZendyContext(): string {
|
|
12
|
-
if (cachedContext === undefined) {
|
|
13
|
-
cachedContext = readFileSync(AGENTS_PATH, "utf-8");
|
|
14
|
-
}
|
|
15
|
-
return cachedContext;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export default function (pi: ExtensionAPI) {
|
|
19
|
-
pi.on("before_agent_start", async (event) => {
|
|
20
|
-
return {
|
|
21
|
-
systemPrompt: `${event.systemPrompt}\n\n${loadZendyContext()}`,
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
}
|