@fpr1m3/opencode-pai-plugin 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 fprime
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # OpenCode PAI Plugin
2
+
3
+ A native OpenCode plugin that implements the **Personal AI Infrastructure (PAI)** logic, replacing legacy hook scripts with a cohesive, lifecycle-aware system.
4
+
5
+ ## Credits & Inspiration
6
+
7
+ This project is an OpenCode-compatible clone of the hook system from **Dan Miessler's** [Personal AI Infrastructure (PAI)](https://github.com/danielmiessler/Personal_AI_Infrastructure) project. A massive shout out to Dan for the architectural vision and the original PAI patterns that this plugin brings to the OpenCode ecosystem.
8
+
9
+ ---
10
+
11
+ **Disclaimer**: This project is independent and is **not** supported by, affiliated with, or endorsed by Dan Miessler or the OpenCode team.
12
+
13
+ ## Features
14
+
15
+ ### 1. Identity & Context Injection
16
+ * **Core Skill Loading**: Automatically injects your `skills/core/SKILL.md` (from `PAI_DIR`) into the system prompt.
17
+ * **Dynamic Substitution**: Supports placeholders like `{{DA}}`, `{{DA_COLOR}}`, and `{{ENGINEER_NAME}}` for personalized interactions.
18
+ * **Project Requirements**: Automatically detects and loads `.opencode/dynamic-requirements.md` from your current project, allowing for task-specific instructions.
19
+
20
+ ### 2. Intelligent History & Logging
21
+ * **Real-time Event Capture**: Logs all tool calls and SDK events to `PAI_DIR/history/raw-outputs` in an analytics-ready JSONL format.
22
+ * **Session Summaries**: Generates human-readable Markdown summaries in `PAI_DIR/history/sessions` at the end of every session, tracking files modified, tools used, and commands executed.
23
+ * **Agent Mapping**: Tracks session-to-agent relationships (e.g., mapping a subagent session to its specialized type).
24
+
25
+ ### 3. Security & Safety
26
+ * **Security Validator**: A built-in firewall that scans Bash commands for dangerous patterns (reverse shells, recursive deletions, prompt injections) via the `permission.ask` hook.
27
+ * **Safe Confirmations**: Automatically triggers a confirmation prompt for risky but potentially legitimate operations like forced Git pushes.
28
+
29
+ ### 4. Interactive Feedback
30
+ * **Real-time Tab Titles**: Updates your terminal tab title *instantly* when a tool starts (e.g., `Running bash...`, `Editing index.ts...`).
31
+ * **Post-Task Summaries**: Updates the tab title with a concise summary of what was accomplished when a task is completed.
32
+
33
+ ## Configuration
34
+
35
+ The plugin centers around the `PAI_DIR` environment variable.
36
+
37
+ | Variable | Description | Default |
38
+ | :--- | :--- | :--- |
39
+ | `PAI_DIR` | Root directory for PAI skills and history | `$XDG_CONFIG_HOME/opencode` |
40
+ | `DA` | Name of your Digital Assistant | `PAI` |
41
+ | `ENGINEER_NAME` | Your name/identity | `Operator` |
42
+ | `DA_COLOR` | UI color theme for your DA | `blue` |
43
+
44
+ ## Quick Start
45
+
46
+ Add the plugin to your global `opencode.json` configuration file (typically located at `~/.config/opencode/opencode.json`). OpenCode will automatically install the plugin from GitHub on its next startup.
47
+
48
+ ```json
49
+ {
50
+ "plugins": [
51
+ "github:fpr1m3/opencode-pai-plugin"
52
+ ]
53
+ }
54
+ ```
55
+
56
+ Upon first run, the plugin will automatically:
57
+ 1. Detect or create your `PAI_DIR` (default: `$XDG_CONFIG_HOME/opencode`).
58
+ 2. Initialize the required directory structure for skills and history.
59
+ 3. Create a default `SKILL.md` core identity if one does not exist.
60
+
61
+ ## Development & Testing
62
+
63
+ We provide scripts to verify the plugin in a pristine environment:
64
+
65
+ * `./scripts/create-test-env.sh`: Creates a fresh, isolated OpenCode project for testing.
66
+ * `./scripts/test-full-flow.sh`: Runs a complete E2E verification of the plugin lifecycle.
67
+
68
+ ## Roadmap / TODO
69
+
70
+ - [ ] **Voice Server Integration**: Implementation of the PAI voice notification server to provide audible feedback on task completion.
71
+ - [ ] **Enhanced Agent Mapping**: More granular tracking of subagent state transitions.
72
+
73
+ ---
74
+
75
+ **Note**: This plugin is designed to work with the PAI ecosystem. While it auto-initializes a basic structure, you can customize your identity by editing `$PAI_DIR/skills/core/SKILL.md`.
76
+
77
+ ---
78
+
79
+ Vibe coded with ❤️ by a mix of **Claude Code** and **OpenCode**.
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from '@opencode-ai/plugin';
2
+ export declare const PAIPlugin: Plugin;
3
+ export default PAIPlugin;
package/dist/index.js ADDED
@@ -0,0 +1,226 @@
1
+ import { Logger } from './lib/logger';
2
+ import { PAI_DIR } from './lib/paths';
3
+ import { validateCommand } from './lib/security';
4
+ import { join } from 'path';
5
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
6
+ /**
7
+ * Ensure the PAI directory structure exists.
8
+ */
9
+ function ensurePAIStructure() {
10
+ const dirs = [
11
+ join(PAI_DIR, 'skills', 'core'),
12
+ join(PAI_DIR, 'history', 'raw-outputs'),
13
+ join(PAI_DIR, 'history', 'sessions'),
14
+ join(PAI_DIR, 'history', 'system-logs'),
15
+ ];
16
+ for (const dir of dirs) {
17
+ if (!existsSync(dir)) {
18
+ try {
19
+ mkdirSync(dir, { recursive: true });
20
+ console.log(`PAI: Created directory ${dir}`);
21
+ }
22
+ catch (e) {
23
+ console.error(`PAI: Failed to create directory ${dir}:`, e);
24
+ }
25
+ }
26
+ }
27
+ const coreSkillPath = join(PAI_DIR, 'skills', 'core', 'SKILL.md');
28
+ if (!existsSync(coreSkillPath)) {
29
+ const defaultSkill = `# PAI Core Identity
30
+ You are {{DA}}, a Personal AI Infrastructure.
31
+ Your primary engineer is {{ENGINEER_NAME}}.
32
+ `;
33
+ try {
34
+ writeFileSync(coreSkillPath, defaultSkill, 'utf-8');
35
+ console.log(`PAI: Created default SKILL.md at ${coreSkillPath}`);
36
+ }
37
+ catch (e) {
38
+ console.error(`PAI: Failed to create default SKILL.md:`, e);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * Check if an event should be skipped to prevent recursive logging.
44
+ */
45
+ function shouldSkipEvent(event, sessionId) {
46
+ // Skip file watcher events for raw-outputs directory or history directory
47
+ if (event.type === 'file.watcher.updated') {
48
+ const file = event.properties?.file;
49
+ if (typeof file === 'string' && (file.includes('raw-outputs/') || file.includes('history/'))) {
50
+ return true;
51
+ }
52
+ }
53
+ // Skip message.updated events with self-referencing diffs
54
+ if (sessionId && event.type === 'message.updated') {
55
+ const info = event.properties?.info;
56
+ const diffs = info?.summary?.diffs;
57
+ if (Array.isArray(diffs)) {
58
+ const hasSelfRef = diffs.some((diff) => typeof diff?.file === 'string' &&
59
+ diff.file.includes('history/') &&
60
+ diff.file.includes(sessionId));
61
+ if (hasSelfRef)
62
+ return true;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+ /**
68
+ * Generate a 4-word tab title summarizing what was done
69
+ */
70
+ function generateTabTitle(completedLine) {
71
+ if (completedLine) {
72
+ const cleanCompleted = completedLine
73
+ .replace(/\*+/g, '')
74
+ .replace(/\[.*?\]/g, '')
75
+ .replace(/🎯\s*COMPLETED:\s*/gi, '')
76
+ .replace(/COMPLETED:\s*/gi, '')
77
+ .trim();
78
+ const words = cleanCompleted.split(/\s+/)
79
+ .filter(word => word.length > 2 && !['the', 'and', 'but', 'for', 'are', 'with', 'this', 'that'].includes(word.toLowerCase()))
80
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
81
+ if (words.length >= 2) {
82
+ return words.slice(0, 4).join(' ');
83
+ }
84
+ }
85
+ return 'PAI Task Done';
86
+ }
87
+ export const PAIPlugin = async ({ worktree }) => {
88
+ let logger = null;
89
+ let currentSessionId = null;
90
+ // Auto-initialize PAI infrastructure if needed
91
+ ensurePAIStructure();
92
+ // Load CORE skill content from $PAI_DIR/skills/core/SKILL.md
93
+ let coreSkillContent = '';
94
+ const coreSkillPath = join(PAI_DIR, 'skills', 'core', 'SKILL.md');
95
+ if (existsSync(coreSkillPath)) {
96
+ try {
97
+ coreSkillContent = readFileSync(coreSkillPath, 'utf-8');
98
+ }
99
+ catch (e) {
100
+ console.error('PAI: Failed to read CORE skill:', e);
101
+ }
102
+ }
103
+ // Dynamic Variable Substitution for System Prompt
104
+ const daName = process.env.DA || 'PAI';
105
+ const engineerName = process.env.ENGINEER_NAME || 'Operator';
106
+ const daColor = process.env.DA_COLOR || 'blue';
107
+ const personalizedSkillContent = coreSkillContent
108
+ .replace(/\{\{DA\}\}/g, daName)
109
+ .replace(/\{\{DA_COLOR\}\}/g, daColor)
110
+ .replace(/\{\{ENGINEER_NAME\}\}/g, engineerName);
111
+ // Load project-specific dynamic requirements if they exist
112
+ let projectRequirements = '';
113
+ const projectReqPath = join(worktree, '.opencode', 'dynamic-requirements.md');
114
+ if (existsSync(projectReqPath)) {
115
+ try {
116
+ projectRequirements = readFileSync(projectReqPath, 'utf-8');
117
+ console.log(`PAI: Loaded project requirements from ${projectReqPath}`);
118
+ }
119
+ catch (e) {
120
+ console.error('PAI: Failed to read project requirements:', e);
121
+ }
122
+ }
123
+ console.log(`PAI Plugin Initialized (Personalized for ${engineerName} & ${daName})`);
124
+ // Ready to serve
125
+ const hooks = {
126
+ event: async ({ event }) => {
127
+ const anyEvent = event;
128
+ // Initialize Logger on session creation
129
+ if (event.type === 'session.created') {
130
+ currentSessionId = anyEvent.properties.info.id;
131
+ logger = new Logger(currentSessionId);
132
+ }
133
+ // Handle generic event logging
134
+ if (logger &&
135
+ event.type !== 'message.part.updated' &&
136
+ !shouldSkipEvent(event, currentSessionId)) {
137
+ logger.logOpenCodeEvent(event);
138
+ }
139
+ // Handle real-time tab title updates (Pre-Tool Use)
140
+ if (anyEvent.type === 'tool.call') {
141
+ const props = anyEvent.properties;
142
+ if (props?.tool === 'Bash' || props?.tool === 'bash') {
143
+ const cmd = props?.input?.command?.split(/\s+/)[0] || 'bash';
144
+ process.stderr.write(`\x1b]0;Running ${cmd}...\x07`);
145
+ }
146
+ else if (props?.tool === 'Edit' || props?.tool === 'Write') {
147
+ const file = props?.input?.file_path?.split('/').pop() || 'file';
148
+ process.stderr.write(`\x1b]0;Editing ${file}...\x07`);
149
+ }
150
+ else if (props?.tool === 'Task') {
151
+ const type = props?.input?.subagent_type || 'agent';
152
+ process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
153
+ }
154
+ }
155
+ // Handle assistant completion (Tab Titles)
156
+ if (event.type === 'message.updated') {
157
+ const info = anyEvent.properties?.info;
158
+ if (info?.author === 'assistant' && info?.content) {
159
+ const content = typeof info.content === 'string' ? info.content : '';
160
+ // Look for COMPLETED: line (can be prefaced by 🎯 or just text)
161
+ const completedMatch = content.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
162
+ if (completedMatch) {
163
+ const completedLine = completedMatch[1].trim();
164
+ // Set Tab Title
165
+ const tabTitle = generateTabTitle(completedLine);
166
+ process.stderr.write(`\x1b]0;${tabTitle}\x07`);
167
+ }
168
+ }
169
+ }
170
+ // Handle session deletion / end
171
+ if (event.type === 'session.deleted') {
172
+ if (logger) {
173
+ await logger.generateSessionSummary();
174
+ logger.flush();
175
+ }
176
+ }
177
+ },
178
+ "tool.execute.after": async (input, output) => {
179
+ if (logger) {
180
+ logger.logToolExecution(input, output);
181
+ }
182
+ },
183
+ "permission.ask": async (permission) => {
184
+ if (permission.tool === 'Bash' || permission.tool === 'bash') {
185
+ const command = permission.arguments?.command || '';
186
+ const result = validateCommand(command);
187
+ if (result.status === 'deny') {
188
+ return {
189
+ status: 'deny',
190
+ feedback: result.feedback
191
+ };
192
+ }
193
+ if (result.status === 'ask') {
194
+ return { status: 'ask' };
195
+ }
196
+ }
197
+ return { status: 'allow' };
198
+ },
199
+ /**
200
+ * Experimental: Inject PAI Core identity into the system prompt
201
+ */
202
+ ...{
203
+ "experimental.chat.system.transform": async (input, output) => {
204
+ const skipAgents = ['title', 'summary', 'compaction'];
205
+ if (input.agent && skipAgents.includes(input.agent.name)) {
206
+ return;
207
+ }
208
+ if (personalizedSkillContent && output.system && output.system.length > 0) {
209
+ // system[0] is typically the caching-sensitive header, so we inject into system[1] or push
210
+ let injection = `\n\n--- PAI CORE IDENTITY ---\n${personalizedSkillContent}\n--- END PAI CORE IDENTITY ---\n\n`;
211
+ if (projectRequirements) {
212
+ injection += `\n\n--- PROJECT DYNAMIC REQUIREMENTS ---\n${projectRequirements}\n--- END PROJECT DYNAMIC REQUIREMENTS ---\n\n`;
213
+ }
214
+ if (output.system.length >= 2) {
215
+ output.system[1] = injection + output.system[1];
216
+ }
217
+ else {
218
+ output.system.push(injection);
219
+ }
220
+ }
221
+ }
222
+ }
223
+ };
224
+ return hooks;
225
+ };
226
+ export default PAIPlugin;
@@ -0,0 +1 @@
1
+ export declare function getCoreContext(baseDir: string, env: NodeJS.ProcessEnv): string;
@@ -0,0 +1,21 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getSkillsDir } from './paths';
4
+ export function getCoreContext(baseDir, env) {
5
+ const skillsDir = getSkillsDir(baseDir);
6
+ const coreSkillPath = join(skillsDir, 'core', 'SKILL.md');
7
+ if (!existsSync(coreSkillPath)) {
8
+ console.warn(`Core skill file not found at ${coreSkillPath}`);
9
+ return '';
10
+ }
11
+ let content = readFileSync(coreSkillPath, 'utf-8');
12
+ // Variable replacement
13
+ const replacements = {
14
+ '{{DA}}': env.DA_NAME || 'PAI',
15
+ '{{ENGINEER_NAME}}': env.USER_NAME || env.USER || 'Engineer',
16
+ };
17
+ for (const [key, value] of Object.entries(replacements)) {
18
+ content = content.replaceAll(key, value);
19
+ }
20
+ return content;
21
+ }
@@ -0,0 +1,35 @@
1
+ import type { Event } from '@opencode-ai/sdk';
2
+ export declare class Logger {
3
+ private sessionId;
4
+ private toolsUsed;
5
+ private filesChanged;
6
+ private commandsExecuted;
7
+ private startTime;
8
+ constructor(sessionId: string);
9
+ private getPSTTimestamp;
10
+ private getEventsFilePath;
11
+ private getSessionMappingFile;
12
+ private getAgentForSession;
13
+ private setAgentForSession;
14
+ logEvent(event: Event): void;
15
+ logOpenCodeEvent(event: Event): void;
16
+ /**
17
+ * Log tool execution from tool.execute.after hook
18
+ *
19
+ * Input structure: { tool: string; sessionID: string; callID: string }
20
+ * Output structure: { title: string; output: string; metadata: any }
21
+ */
22
+ logToolExecution(input: {
23
+ tool: string;
24
+ sessionID: string;
25
+ callID: string;
26
+ }, output: {
27
+ title: string;
28
+ output: string;
29
+ metadata: any;
30
+ }): void;
31
+ generateSessionSummary(): Promise<string | null>;
32
+ logError(context: string, error: any): void;
33
+ private writeEvent;
34
+ flush(): void;
35
+ }
@@ -0,0 +1,264 @@
1
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
4
+ import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
5
+ import { redactString, redactObject } from './redaction';
6
+ export class Logger {
7
+ sessionId;
8
+ toolsUsed = new Set();
9
+ filesChanged = new Set();
10
+ commandsExecuted = [];
11
+ startTime = Date.now();
12
+ constructor(sessionId) {
13
+ this.sessionId = sessionId;
14
+ }
15
+ // Get PST timestamp
16
+ getPSTTimestamp() {
17
+ const date = new Date();
18
+ const pstDate = new Date(date.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
19
+ const year = pstDate.getFullYear();
20
+ const month = String(pstDate.getMonth() + 1).padStart(2, '0');
21
+ const day = String(pstDate.getDate()).padStart(2, '0');
22
+ const hours = String(pstDate.getHours()).padStart(2, '0');
23
+ const minutes = String(pstDate.getMinutes()).padStart(2, '0');
24
+ const seconds = String(pstDate.getSeconds()).padStart(2, '0');
25
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} PST`;
26
+ }
27
+ getEventsFilePath() {
28
+ const now = new Date();
29
+ const pstDate = new Date(now.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
30
+ const year = pstDate.getFullYear();
31
+ const month = String(pstDate.getMonth() + 1).padStart(2, '0');
32
+ const day = String(pstDate.getDate()).padStart(2, '0');
33
+ const filename = `${year}-${month}-${day}_all-events.jsonl`;
34
+ const filePath = getHistoryFilePath('raw-outputs', filename);
35
+ const dir = dirname(filePath);
36
+ if (!existsSync(dir)) {
37
+ mkdirSync(dir, { recursive: true });
38
+ }
39
+ return filePath;
40
+ }
41
+ getSessionMappingFile() {
42
+ return join(PAI_DIR, 'agent-sessions.json');
43
+ }
44
+ getAgentForSession(sessionId) {
45
+ try {
46
+ const mappingFile = this.getSessionMappingFile();
47
+ if (existsSync(mappingFile)) {
48
+ const mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
49
+ return mappings[sessionId] || 'pai';
50
+ }
51
+ }
52
+ catch (error) {
53
+ // Ignore errors, default to pai
54
+ }
55
+ return 'pai';
56
+ }
57
+ setAgentForSession(sessionId, agentName) {
58
+ try {
59
+ const mappingFile = this.getSessionMappingFile();
60
+ let mappings = {};
61
+ if (existsSync(mappingFile)) {
62
+ mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
63
+ }
64
+ mappings[sessionId] = agentName;
65
+ writeFileSync(mappingFile, JSON.stringify(mappings, null, 2), 'utf-8');
66
+ }
67
+ catch (error) {
68
+ // Silently fail - don't block
69
+ }
70
+ }
71
+ logEvent(event) {
72
+ // Legacy method, not used much as we use logOpenCodeEvent
73
+ // But might be called from index.ts if I didn't update all calls
74
+ this.logOpenCodeEvent(event);
75
+ }
76
+ // Method to log generic OpenCode event
77
+ logOpenCodeEvent(event) {
78
+ const anyEvent = event;
79
+ const timestamp = anyEvent.timestamp || Date.now();
80
+ const payload = {
81
+ ...anyEvent.properties,
82
+ timestamp: timestamp
83
+ };
84
+ // Track stats for summary
85
+ if (anyEvent.type === 'tool.call' || anyEvent.type === 'tool.execute.before') {
86
+ const props = anyEvent.properties;
87
+ const tool = props?.tool || props?.tool_name;
88
+ if (tool) {
89
+ this.toolsUsed.add(tool);
90
+ if (tool === 'Bash' || tool === 'bash') {
91
+ const command = props?.input?.command || props?.tool_input?.command;
92
+ if (command)
93
+ this.commandsExecuted.push(redactString(command));
94
+ }
95
+ if (['Edit', 'Write', 'edit', 'write'].includes(tool)) {
96
+ const path = props?.input?.file_path || props?.input?.path ||
97
+ props?.tool_input?.file_path || props?.tool_input?.path;
98
+ if (path)
99
+ this.filesChanged.add(path);
100
+ }
101
+ }
102
+ }
103
+ this.writeEvent(anyEvent.type, redactObject(payload));
104
+ }
105
+ /**
106
+ * Log tool execution from tool.execute.after hook
107
+ *
108
+ * Input structure: { tool: string; sessionID: string; callID: string }
109
+ * Output structure: { title: string; output: string; metadata: any }
110
+ */
111
+ logToolExecution(input, output) {
112
+ const toolName = input.tool;
113
+ const sessionId = this.sessionId;
114
+ this.toolsUsed.add(toolName);
115
+ // Extract metadata - may contain additional tool info
116
+ const metadata = output.metadata || {};
117
+ // Logic to update agent mapping based on Task tool spawning subagents
118
+ if (toolName === 'Task' && metadata?.subagent_type) {
119
+ this.setAgentForSession(sessionId, metadata.subagent_type);
120
+ }
121
+ else if (toolName === 'subagent_stop' || toolName === 'stop') {
122
+ this.setAgentForSession(sessionId, 'pai');
123
+ }
124
+ const payload = {
125
+ tool_name: toolName,
126
+ tool_title: output.title,
127
+ tool_output: output.output,
128
+ tool_metadata: metadata,
129
+ call_id: input.callID,
130
+ };
131
+ this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
132
+ }
133
+ async generateSessionSummary() {
134
+ try {
135
+ const now = new Date();
136
+ const timestamp = now.toISOString()
137
+ .replace(/:/g, '')
138
+ .replace(/\..+/, '')
139
+ .replace('T', '-'); // YYYY-MM-DD-HHMMSS
140
+ const yearMonth = timestamp.substring(0, 7);
141
+ const date = timestamp.substring(0, 10);
142
+ const time = timestamp.substring(11).replace(/-/g, ':');
143
+ const duration = Math.round((Date.now() - this.startTime) / 60000);
144
+ const sessionDir = join(HISTORY_DIR, 'sessions', yearMonth);
145
+ if (!existsSync(sessionDir)) {
146
+ mkdirSync(sessionDir, { recursive: true });
147
+ }
148
+ const focus = this.filesChanged.size > 0 ? 'development' : 'research';
149
+ const filename = `${timestamp}_SESSION_${focus}.md`;
150
+ const filePath = join(sessionDir, filename);
151
+ const summary = `---
152
+ capture_type: SESSION
153
+ timestamp: ${new Date().toISOString()}
154
+ session_id: ${this.sessionId}
155
+ duration_minutes: ${duration}
156
+ executor: pai
157
+ ---
158
+
159
+ # Session: ${focus}
160
+
161
+ **Date:** ${date}
162
+ **Time:** ${time}
163
+ **Session ID:** ${this.sessionId}
164
+
165
+ ---
166
+
167
+ ## Session Overview
168
+
169
+ **Focus:** ${focus === 'development' ? 'Software development and code modification' : 'Research and general assistance'}
170
+ **Duration:** ${duration} minutes
171
+
172
+ ---
173
+
174
+ ## Tools Used
175
+
176
+ ${this.toolsUsed.size > 0 ? Array.from(this.toolsUsed).map(t => `- ${t}`).sort().join('\n') : '- None recorded'}
177
+
178
+ ---
179
+
180
+ ## Files Modified
181
+
182
+ ${this.filesChanged.size > 0 ? Array.from(this.filesChanged).map(f => `- \`${f}\``).sort().join('\n') : '- None recorded'}
183
+
184
+ **Total Files Changed:** ${this.filesChanged.size}
185
+
186
+ ---
187
+
188
+ ## Commands Executed
189
+
190
+ ${this.commandsExecuted.length > 0 ? '```bash\n' + this.commandsExecuted.slice(0, 20).join('\n') + '\n```' : 'None recorded'}
191
+
192
+ ---
193
+
194
+ ## Notes
195
+
196
+ This session summary was automatically generated by the PAI OpenCode Plugin.
197
+
198
+ ---
199
+
200
+ **Session Outcome:** Completed
201
+ **Generated:** ${new Date().toISOString()}
202
+ `;
203
+ writeFileSync(filePath, summary);
204
+ return filePath;
205
+ }
206
+ catch (error) {
207
+ this.logError('SessionSummary', error);
208
+ return null;
209
+ }
210
+ }
211
+ logError(context, error) {
212
+ try {
213
+ const now = new Date();
214
+ const pstDate = new Date(now.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
215
+ const year = pstDate.getFullYear();
216
+ const month = String(pstDate.getMonth() + 1).padStart(2, '0');
217
+ const day = String(pstDate.getDate()).padStart(2, '0');
218
+ const filename = `${year}-${month}-${day}_errors.log`;
219
+ const filePath = getHistoryFilePath('system-logs', filename);
220
+ const dir = dirname(filePath);
221
+ if (!existsSync(dir)) {
222
+ mkdirSync(dir, { recursive: true });
223
+ }
224
+ const timestamp = this.getPSTTimestamp();
225
+ const errorMessage = error instanceof Error ? error.message : String(error);
226
+ const stack = error instanceof Error ? error.stack : '';
227
+ const logEntry = `[${timestamp}] [${context}] ${errorMessage}\n${stack}\n-------------------\n`;
228
+ appendFileSync(filePath, logEntry, 'utf-8');
229
+ }
230
+ catch (e) {
231
+ // Intentionally silent - TUI protection
232
+ }
233
+ }
234
+ // Core write method
235
+ writeEvent(eventType, payload, toolName, toolInput) {
236
+ const sessionId = this.sessionId;
237
+ let agentName = this.getAgentForSession(sessionId);
238
+ // Create base event object
239
+ let hookEvent = {
240
+ source_app: agentName,
241
+ session_id: sessionId,
242
+ hook_event_type: eventType,
243
+ payload: payload,
244
+ timestamp: Date.now(),
245
+ timestamp_pst: this.getPSTTimestamp()
246
+ };
247
+ // Enrich with agent instance metadata if this is a Task tool call
248
+ if (toolName && toolInput && isAgentSpawningCall(toolName, toolInput)) {
249
+ hookEvent = enrichEventWithAgentMetadata(hookEvent, toolInput, payload.description // Assuming description is available in payload if passed
250
+ );
251
+ }
252
+ try {
253
+ const eventsFile = this.getEventsFilePath();
254
+ const jsonLine = JSON.stringify(hookEvent) + '\n';
255
+ appendFileSync(eventsFile, jsonLine, 'utf-8');
256
+ }
257
+ catch (error) {
258
+ this.logError('EventCapture', error);
259
+ }
260
+ }
261
+ flush() {
262
+ // No-op for now as we append synchronously
263
+ }
264
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Metadata Extraction Library for UOCS Enhancement
3
+ *
4
+ * Extracts agent instance IDs, parent-child relationships, and session info
5
+ * from Task tool calls and other tool inputs.
6
+ *
7
+ * Design Philosophy: Optional extraction with graceful fallbacks
8
+ * - If instance IDs are present in descriptions/prompts, extract them
9
+ * - If not present, fall back to agent type only
10
+ * - Never fail - always return usable metadata
11
+ */
12
+ export interface AgentInstanceMetadata {
13
+ agent_instance_id?: string;
14
+ agent_type?: string;
15
+ instance_number?: number;
16
+ parent_session_id?: string;
17
+ parent_task_id?: string;
18
+ }
19
+ /**
20
+ * Extract agent instance ID from Task tool input
21
+ *
22
+ * Looks for patterns in priority order:
23
+ * 1. [agent-type-N] in description (e.g., "Research topic [perplexity-researcher-1]")
24
+ * 2. [AGENT_INSTANCE: agent-type-N] in prompt
25
+ * 3. subagent_type field (fallback to just type, no instance number)
26
+ *
27
+ * @param toolInput The tool input object from PreToolUse/PostToolUse hooks
28
+ * @param description Optional description field from tool input
29
+ * @returns Metadata object with extracted information
30
+ */
31
+ export declare function extractAgentInstanceId(toolInput: any, description?: string): AgentInstanceMetadata;
32
+ /**
33
+ * Enrich event with agent metadata
34
+ *
35
+ * Takes a base event object and adds agent instance metadata to it.
36
+ * Returns a new object with merged metadata.
37
+ *
38
+ * @param event Base event object (from PreToolUse/PostToolUse)
39
+ * @param toolInput Tool input object
40
+ * @param description Optional description field
41
+ * @returns Enriched event with agent metadata
42
+ */
43
+ export declare function enrichEventWithAgentMetadata(event: any, toolInput: any, description?: string): any;
44
+ /**
45
+ * Check if a tool call is spawning a subagent
46
+ *
47
+ * @param toolName Name of the tool being called
48
+ * @param toolInput Tool input object
49
+ * @returns true if this is a Task tool call spawning an agent
50
+ */
51
+ export declare function isAgentSpawningCall(toolName: string, toolInput: any): boolean;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Metadata Extraction Library for UOCS Enhancement
3
+ *
4
+ * Extracts agent instance IDs, parent-child relationships, and session info
5
+ * from Task tool calls and other tool inputs.
6
+ *
7
+ * Design Philosophy: Optional extraction with graceful fallbacks
8
+ * - If instance IDs are present in descriptions/prompts, extract them
9
+ * - If not present, fall back to agent type only
10
+ * - Never fail - always return usable metadata
11
+ */
12
+ /**
13
+ * Validate that an ID string contains only safe characters
14
+ * Allows alphanumeric, hyphens, and underscores.
15
+ * Prevents path traversal and injection attacks.
16
+ */
17
+ function isValidId(id) {
18
+ return /^[a-zA-Z0-9\-_]+$/.test(id);
19
+ }
20
+ /**
21
+ * Extract agent instance ID from Task tool input
22
+ *
23
+ * Looks for patterns in priority order:
24
+ * 1. [agent-type-N] in description (e.g., "Research topic [perplexity-researcher-1]")
25
+ * 2. [AGENT_INSTANCE: agent-type-N] in prompt
26
+ * 3. subagent_type field (fallback to just type, no instance number)
27
+ *
28
+ * @param toolInput The tool input object from PreToolUse/PostToolUse hooks
29
+ * @param description Optional description field from tool input
30
+ * @returns Metadata object with extracted information
31
+ */
32
+ export function extractAgentInstanceId(toolInput, description) {
33
+ const result = {};
34
+ // Strategy 1: Extract from description [agent-type-N]
35
+ // Example: "Research consumer complaints [perplexity-researcher-1]"
36
+ if (description) {
37
+ const descMatch = description.match(/\[([a-z-]+-researcher)-(\d+)\]/);
38
+ if (descMatch) {
39
+ result.agent_type = descMatch[1];
40
+ result.instance_number = parseInt(descMatch[2], 10);
41
+ result.agent_instance_id = `${result.agent_type}-${result.instance_number}`;
42
+ }
43
+ }
44
+ // Strategy 2: Extract from prompt [AGENT_INSTANCE: ...]
45
+ // Example: "[AGENT_INSTANCE: perplexity-researcher-1]"
46
+ if (!result.agent_instance_id && toolInput?.prompt && typeof toolInput.prompt === 'string') {
47
+ const promptMatch = toolInput.prompt.match(/\[AGENT_INSTANCE:\s*([^\]]+)\]/);
48
+ if (promptMatch) {
49
+ const extractedId = promptMatch[1].trim();
50
+ // Security: Validate ID format to prevent injection
51
+ if (isValidId(extractedId)) {
52
+ result.agent_instance_id = extractedId;
53
+ // Parse agent type and instance number from ID
54
+ const parts = result.agent_instance_id.match(/^([a-z-]+)-(\d+)$/);
55
+ if (parts) {
56
+ result.agent_type = parts[1];
57
+ result.instance_number = parseInt(parts[2], 10);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ // Strategy 3: Extract parent session from prompt
63
+ // Example: "[PARENT_SESSION: b7062b5a-03d3-4168-9555-a748e0b2efa3]"
64
+ if (toolInput?.prompt && typeof toolInput.prompt === 'string') {
65
+ const parentSessionMatch = toolInput.prompt.match(/\[PARENT_SESSION:\s*([^\]]+)\]/);
66
+ if (parentSessionMatch) {
67
+ const extractedId = parentSessionMatch[1].trim();
68
+ if (isValidId(extractedId)) {
69
+ result.parent_session_id = extractedId;
70
+ }
71
+ }
72
+ // Extract parent task from prompt
73
+ // Example: "[PARENT_TASK: research_1731445892345]"
74
+ const parentTaskMatch = toolInput.prompt.match(/\[PARENT_TASK:\s*([^\]]+)\]/);
75
+ if (parentTaskMatch) {
76
+ const extractedId = parentTaskMatch[1].trim();
77
+ if (isValidId(extractedId)) {
78
+ result.parent_task_id = extractedId;
79
+ }
80
+ }
81
+ }
82
+ // Strategy 4: Fallback to subagent_type if available (no instance number)
83
+ // This ensures we at least capture the agent type even without instance IDs
84
+ if (!result.agent_type && toolInput?.subagent_type) {
85
+ result.agent_type = toolInput.subagent_type;
86
+ }
87
+ return result;
88
+ }
89
+ /**
90
+ * Enrich event with agent metadata
91
+ *
92
+ * Takes a base event object and adds agent instance metadata to it.
93
+ * Returns a new object with merged metadata.
94
+ *
95
+ * @param event Base event object (from PreToolUse/PostToolUse)
96
+ * @param toolInput Tool input object
97
+ * @param description Optional description field
98
+ * @returns Enriched event with agent metadata
99
+ */
100
+ export function enrichEventWithAgentMetadata(event, toolInput, description) {
101
+ const metadata = extractAgentInstanceId(toolInput, description);
102
+ // Only add fields that have values (keep events clean)
103
+ const enrichedEvent = { ...event };
104
+ if (metadata.agent_instance_id) {
105
+ enrichedEvent.agent_instance_id = metadata.agent_instance_id;
106
+ }
107
+ if (metadata.agent_type) {
108
+ enrichedEvent.agent_type = metadata.agent_type;
109
+ }
110
+ if (metadata.instance_number !== undefined) {
111
+ enrichedEvent.instance_number = metadata.instance_number;
112
+ }
113
+ if (metadata.parent_session_id) {
114
+ enrichedEvent.parent_session_id = metadata.parent_session_id;
115
+ }
116
+ if (metadata.parent_task_id) {
117
+ enrichedEvent.parent_task_id = metadata.parent_task_id;
118
+ }
119
+ return enrichedEvent;
120
+ }
121
+ /**
122
+ * Check if a tool call is spawning a subagent
123
+ *
124
+ * @param toolName Name of the tool being called
125
+ * @param toolInput Tool input object
126
+ * @returns true if this is a Task tool call spawning an agent
127
+ */
128
+ export function isAgentSpawningCall(toolName, toolInput) {
129
+ return toolName === 'Task' && toolInput?.subagent_type !== undefined;
130
+ }
@@ -0,0 +1 @@
1
+ export declare function notifyVoiceServer(message: string): Promise<void>;
@@ -0,0 +1,12 @@
1
+ export async function notifyVoiceServer(message) {
2
+ try {
3
+ await fetch('http://localhost:8888/notify', {
4
+ method: 'POST',
5
+ headers: { 'Content-Type': 'application/json' },
6
+ body: JSON.stringify({ message }),
7
+ });
8
+ }
9
+ catch (error) {
10
+ // Ignore errors if server is not running
11
+ }
12
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * PAI Path Resolution - Single Source of Truth
3
+ *
4
+ * This module provides consistent path resolution across all PAI hooks.
5
+ * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude
6
+ *
7
+ * Usage in hooks:
8
+ * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/paths';
9
+ */
10
+ export declare const PAI_DIR: string;
11
+ /**
12
+ * Common PAI directories
13
+ */
14
+ export declare const HOOKS_DIR: string;
15
+ export declare const SKILLS_DIR: string;
16
+ export declare const AGENTS_DIR: string;
17
+ export declare const HISTORY_DIR: string;
18
+ export declare const COMMANDS_DIR: string;
19
+ /**
20
+ * Helper to get history file path with date-based organization
21
+ */
22
+ export declare function getHistoryFilePath(subdir: string, filename: string): string;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * PAI Path Resolution - Single Source of Truth
3
+ *
4
+ * This module provides consistent path resolution across all PAI hooks.
5
+ * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude
6
+ *
7
+ * Usage in hooks:
8
+ * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/paths';
9
+ */
10
+ import { homedir } from 'os';
11
+ import { resolve, join } from 'path';
12
+ import { existsSync } from 'fs';
13
+ /**
14
+ * Smart PAI_DIR detection with fallback
15
+ * Priority:
16
+ * 1. PAI_DIR environment variable (if set)
17
+ * 2. $XDG_CONFIG_HOME/opencode (standard XDG location)
18
+ * 3. ~/.config/opencode (fallback if XDG_CONFIG_HOME is not set)
19
+ */
20
+ const XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
21
+ export const PAI_DIR = process.env.PAI_DIR
22
+ ? resolve(process.env.PAI_DIR)
23
+ : resolve(XDG_CONFIG_HOME, 'opencode');
24
+ /**
25
+ * Common PAI directories
26
+ */
27
+ export const HOOKS_DIR = join(PAI_DIR, 'hooks');
28
+ export const SKILLS_DIR = join(PAI_DIR, 'skills');
29
+ export const AGENTS_DIR = join(PAI_DIR, 'agents');
30
+ export const HISTORY_DIR = join(PAI_DIR, 'history');
31
+ export const COMMANDS_DIR = join(PAI_DIR, 'commands');
32
+ /**
33
+ * Validate PAI directory structure on first import
34
+ * This fails fast with a clear error if PAI is misconfigured
35
+ */
36
+ function validatePAIStructure() {
37
+ // Only validate if we are actually in a context where we expect PAI to exist.
38
+ // For the plugin, we might not want to hard crash if the user hasn't set it up yet,
39
+ // but PAI plugin implies PAI usage.
40
+ // We will log a warning instead of exit(1) to be safer in a plugin environment.
41
+ if (!existsSync(PAI_DIR)) {
42
+ // console.warn(`⚠️ PAI_DIR does not exist: ${PAI_DIR}`);
43
+ }
44
+ }
45
+ validatePAIStructure();
46
+ /**
47
+ * Helper to get history file path with date-based organization
48
+ */
49
+ export function getHistoryFilePath(subdir, filename) {
50
+ const now = new Date();
51
+ const pstDate = new Date(now.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
52
+ const year = pstDate.getFullYear();
53
+ const month = String(pstDate.getMonth() + 1).padStart(2, '0');
54
+ return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);
55
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Redaction utility to scrub sensitive data from logs
3
+ */
4
+ export declare function redactString(str: string): string;
5
+ export declare function redactObject(obj: any, visited?: WeakSet<any>): any;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Redaction utility to scrub sensitive data from logs
3
+ */
4
+ const SENSITIVE_KEYS = [
5
+ 'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd',
6
+ 'auth', 'credential', 'private_key', 'client_secret', 'access_key'
7
+ ];
8
+ const SECRET_PATTERNS = [
9
+ // AWS Access Key ID
10
+ /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
11
+ // GitHub Personal Access Token (classic)
12
+ /\bghp_[a-zA-Z0-9]{36}\b/g,
13
+ // Generic Private Key
14
+ /-----BEGIN [A-Z ]+ PRIVATE KEY-----/g,
15
+ // Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars)
16
+ /\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g,
17
+ ];
18
+ // Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive
19
+ // This catches "export AWS_SECRET_KEY=..." or JSON "password": "..."
20
+ // We construct this dynamically from SENSITIVE_KEYS
21
+ const SENSITIVE_KEY_PATTERN = new RegExp(`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, 'gi');
22
+ export function redactString(str) {
23
+ if (!str)
24
+ return str;
25
+ let redacted = str;
26
+ // 1. Redact specific patterns (like AWS keys)
27
+ for (const pattern of SECRET_PATTERNS) {
28
+ redacted = redacted.replace(pattern, '[REDACTED]');
29
+ }
30
+ // 2. Redact key-value pairs where key suggests sensitivity
31
+ // We use a callback to preserve the key and redact the value
32
+ redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => {
33
+ // If value is already redacted, skip
34
+ if (value === '[REDACTED]')
35
+ return match;
36
+ // Replace the value part with [REDACTED]
37
+ return match.replace(value, '[REDACTED]');
38
+ });
39
+ return redacted;
40
+ }
41
+ export function redactObject(obj, visited = new WeakSet()) {
42
+ if (obj === null || obj === undefined)
43
+ return obj;
44
+ if (typeof obj === 'string') {
45
+ return redactString(obj);
46
+ }
47
+ if (typeof obj !== 'object') {
48
+ return obj;
49
+ }
50
+ if (obj instanceof Date) {
51
+ return obj;
52
+ }
53
+ if (visited.has(obj)) {
54
+ return '[CIRCULAR]';
55
+ }
56
+ visited.add(obj);
57
+ if (Array.isArray(obj)) {
58
+ return obj.map(item => redactObject(item, visited));
59
+ }
60
+ if (typeof obj === 'object') {
61
+ const newObj = {};
62
+ for (const [key, value] of Object.entries(obj)) {
63
+ // If the key itself is sensitive, redact the value blindly if it's a string/number
64
+ const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k));
65
+ if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) {
66
+ newObj[key] = '[REDACTED]';
67
+ }
68
+ else {
69
+ newObj[key] = redactObject(value, visited);
70
+ }
71
+ }
72
+ return newObj;
73
+ }
74
+ return obj;
75
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Security Library for PAI Plugin
3
+ * Ported from legacy security-validator.ts
4
+ */
5
+ export interface SecurityResult {
6
+ status: 'allow' | 'deny' | 'ask';
7
+ category?: string;
8
+ feedback?: string;
9
+ }
10
+ export declare function validateCommand(command: string): SecurityResult;
@@ -0,0 +1,57 @@
1
+ import { redactString } from './redaction';
2
+ const REVERSE_SHELL_PATTERNS = [
3
+ /\/dev\/(tcp|udp)\/[0-9]/,
4
+ /bash\s+-i\s+>&?\s*\/dev\//,
5
+ ];
6
+ const INSTRUCTION_OVERRIDE_PATTERNS = [
7
+ /ignore\s+(all\s+)?previous\s+instructions?/i,
8
+ /disregard\s+(all\s+)?(prior|previous)\s+(instructions?|rules?)/i,
9
+ ];
10
+ const CATASTROPHIC_DELETION_PATTERNS = [
11
+ /\s+~\/?(\s*$|\s+)/,
12
+ /\brm\s+(-[rfivd]+\s+)*\S+\s+~\/?/,
13
+ /\brm\s+(-[rfivd]+\s+)*\.\/\s*$/,
14
+ /\brm\s+(-[rfivd]+\s+)*\.\.\/\s*$/,
15
+ /\brm\s+(-[rfivd]+\s+)*\/\s*$/,
16
+ ];
17
+ const DANGEROUS_FILE_OPS_PATTERNS = [
18
+ /\bchmod\s+(-R\s+)?0{3,}/,
19
+ ];
20
+ const DANGEROUS_GIT_PATTERNS = [
21
+ /\bgit\s+push\s+.*(-f\b|--force)/i,
22
+ /\bgit\s+reset\s+--hard/i,
23
+ ];
24
+ const BLOCK_CATEGORIES = [
25
+ { category: 'reverse_shell', patterns: REVERSE_SHELL_PATTERNS },
26
+ { category: 'instruction_override', patterns: INSTRUCTION_OVERRIDE_PATTERNS },
27
+ { category: 'catastrophic_deletion', patterns: CATASTROPHIC_DELETION_PATTERNS },
28
+ { category: 'dangerous_file_ops', patterns: DANGEROUS_FILE_OPS_PATTERNS },
29
+ ];
30
+ const ASK_CATEGORIES = [
31
+ { category: 'dangerous_git', patterns: DANGEROUS_GIT_PATTERNS },
32
+ ];
33
+ export function validateCommand(command) {
34
+ for (const { category, patterns } of BLOCK_CATEGORIES) {
35
+ for (const pattern of patterns) {
36
+ if (pattern.test(command)) {
37
+ return {
38
+ status: 'deny',
39
+ category,
40
+ feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${redactString(command).slice(0, 50)}...`,
41
+ };
42
+ }
43
+ }
44
+ }
45
+ for (const { category, patterns } of ASK_CATEGORIES) {
46
+ for (const pattern of patterns) {
47
+ if (pattern.test(command)) {
48
+ return {
49
+ status: 'ask',
50
+ category,
51
+ feedback: `⚠️ DANGEROUS: ${category} operation requires confirmation.`,
52
+ };
53
+ }
54
+ }
55
+ }
56
+ return { status: 'allow' };
57
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@fpr1m3/opencode-pai-plugin",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Personal AI Infrastructure (PAI) plugin for OpenCode",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/fpr1m3/opencode-pai-plugin.git"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "test": "bun test",
21
+ "dev": "tsc --watch",
22
+ "prepare": "bun run build"
23
+ },
24
+ "dependencies": {
25
+ "@opencode-ai/plugin": "^1.0.180",
26
+ "@opencode-ai/sdk": "^1.0.180",
27
+ "glob": "^13.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "latest",
31
+ "typescript": "^5.0.0"
32
+ }
33
+ }