@tekmidian/pai 0.3.1 → 0.4.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/dist/cli/index.mjs +352 -33
- package/dist/cli/index.mjs.map +1 -1
- package/dist/hooks/capture-all-events.mjs +238 -0
- package/dist/hooks/capture-all-events.mjs.map +7 -0
- package/dist/hooks/capture-session-summary.mjs +198 -0
- package/dist/hooks/capture-session-summary.mjs.map +7 -0
- package/dist/hooks/capture-tool-output.mjs +105 -0
- package/dist/hooks/capture-tool-output.mjs.map +7 -0
- package/dist/hooks/cleanup-session-files.mjs +129 -0
- package/dist/hooks/cleanup-session-files.mjs.map +7 -0
- package/dist/hooks/context-compression-hook.mjs +283 -0
- package/dist/hooks/context-compression-hook.mjs.map +7 -0
- package/dist/hooks/initialize-session.mjs +206 -0
- package/dist/hooks/initialize-session.mjs.map +7 -0
- package/dist/hooks/load-core-context.mjs +110 -0
- package/dist/hooks/load-core-context.mjs.map +7 -0
- package/dist/hooks/load-project-context.mjs +548 -0
- package/dist/hooks/load-project-context.mjs.map +7 -0
- package/dist/hooks/security-validator.mjs +159 -0
- package/dist/hooks/security-validator.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +625 -0
- package/dist/hooks/stop-hook.mjs.map +7 -0
- package/dist/hooks/subagent-stop-hook.mjs +152 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +7 -0
- package/dist/hooks/sync-todo-to-md.mjs +322 -0
- package/dist/hooks/sync-todo-to-md.mjs.map +7 -0
- package/dist/hooks/update-tab-on-action.mjs +90 -0
- package/dist/hooks/update-tab-on-action.mjs.map +7 -0
- package/dist/hooks/update-tab-titles.mjs +55 -0
- package/dist/hooks/update-tab-titles.mjs.map +7 -0
- package/package.json +4 -2
- package/scripts/build-hooks.mjs +51 -0
- package/src/hooks/pre-compact.sh +4 -0
- package/src/hooks/session-stop.sh +4 -0
- package/src/hooks/ts/capture-all-events.ts +179 -0
- package/src/hooks/ts/lib/detect-environment.ts +53 -0
- package/src/hooks/ts/lib/metadata-extraction.ts +144 -0
- package/src/hooks/ts/lib/pai-paths.ts +124 -0
- package/src/hooks/ts/lib/project-utils.ts +914 -0
- package/src/hooks/ts/post-tool-use/capture-tool-output.ts +78 -0
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +230 -0
- package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +145 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +155 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +258 -0
- package/src/hooks/ts/session-end/capture-session-summary.ts +185 -0
- package/src/hooks/ts/session-start/initialize-session.ts +155 -0
- package/src/hooks/ts/session-start/load-core-context.ts +104 -0
- package/src/hooks/ts/session-start/load-project-context.ts +394 -0
- package/src/hooks/ts/stop/stop-hook.ts +407 -0
- package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +212 -0
- package/src/hooks/ts/user-prompt/cleanup-session-files.ts +45 -0
- package/src/hooks/ts/user-prompt/update-tab-titles.ts +88 -0
- package/tab-color-command.sh +24 -0
- package/templates/ai-steering-rules.template.md +58 -0
- package/templates/pai-skill.template.md +24 -0
- package/templates/skills/createskill-skill.template.md +78 -0
- package/templates/skills/history-system.template.md +371 -0
- package/templates/skills/hook-system.template.md +913 -0
- package/templates/skills/sessions-skill.template.md +102 -0
- package/templates/skills/skill-system.template.md +214 -0
- package/templates/skills/terminal-tabs.template.md +120 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build TypeScript hooks into standalone .mjs files using esbuild.
|
|
4
|
+
*
|
|
5
|
+
* Each hook is fully self-contained — lib/ dependencies are inlined.
|
|
6
|
+
* Output: dist/hooks/<name>.mjs with #!/usr/bin/env node shebang.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { buildSync } from "esbuild";
|
|
10
|
+
import { readdirSync, statSync, chmodSync } from "fs";
|
|
11
|
+
import { join, relative, basename } from "path";
|
|
12
|
+
|
|
13
|
+
const HOOKS_SRC = "src/hooks/ts";
|
|
14
|
+
const HOOKS_OUT = "dist/hooks";
|
|
15
|
+
|
|
16
|
+
// Collect all .ts entry points (skip lib/ — those are bundled into each hook)
|
|
17
|
+
function collectEntryPoints(dir) {
|
|
18
|
+
const entries = [];
|
|
19
|
+
for (const name of readdirSync(dir)) {
|
|
20
|
+
const full = join(dir, name);
|
|
21
|
+
if (name === "lib") continue;
|
|
22
|
+
if (statSync(full).isDirectory()) {
|
|
23
|
+
entries.push(...collectEntryPoints(full));
|
|
24
|
+
} else if (name.endsWith(".ts")) {
|
|
25
|
+
entries.push(full);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return entries;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const entryPoints = collectEntryPoints(HOOKS_SRC);
|
|
32
|
+
|
|
33
|
+
console.log(`Building ${entryPoints.length} hooks with esbuild...`);
|
|
34
|
+
|
|
35
|
+
for (const entry of entryPoints) {
|
|
36
|
+
const outfile = join(HOOKS_OUT, basename(entry).replace(/\.ts$/, ".mjs"));
|
|
37
|
+
|
|
38
|
+
buildSync({
|
|
39
|
+
entryPoints: [entry],
|
|
40
|
+
bundle: true,
|
|
41
|
+
platform: "node",
|
|
42
|
+
target: "node20",
|
|
43
|
+
format: "esm",
|
|
44
|
+
outfile,
|
|
45
|
+
sourcemap: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
chmodSync(outfile, 0o755);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`✔ ${entryPoints.length} hooks built to ${HOOKS_OUT}/`);
|
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
PAI_OS="pai"
|
|
10
10
|
|
|
11
|
+
# Set tab color to working state while compacting
|
|
12
|
+
TAB_COLOR="${PAI_DIR:-$HOME/.claude}/tab-color-command.sh"
|
|
13
|
+
[[ -x "$TAB_COLOR" ]] && "$TAB_COLOR" working
|
|
14
|
+
|
|
11
15
|
# Bail gracefully if pai is not installed
|
|
12
16
|
command -v "$PAI_OS" &>/dev/null || exit 0
|
|
13
17
|
command -v sqlite3 &>/dev/null || exit 0
|
|
@@ -90,4 +90,8 @@ fi
|
|
|
90
90
|
|
|
91
91
|
"$PAI_OS" session handover "$PROJECT_SLUG" latest 2>/dev/null || true
|
|
92
92
|
|
|
93
|
+
# Set tab color to completed state when session ends
|
|
94
|
+
TAB_COLOR="${PAI_DIR:-$HOME/.claude}/tab-color-command.sh"
|
|
95
|
+
[[ -x "$TAB_COLOR" ]] && "$TAB_COLOR" completed
|
|
96
|
+
|
|
93
97
|
exit 0
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Capture All Events Hook
|
|
4
|
+
* Captures ALL Claude Code hook events (not just tools) to JSONL
|
|
5
|
+
* This replaces the Python send_event.py hook
|
|
6
|
+
* Enhanced with agent instance metadata extraction
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync, writeFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { PAI_DIR, HISTORY_DIR } from './lib/pai-paths';
|
|
12
|
+
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './lib/metadata-extraction';
|
|
13
|
+
|
|
14
|
+
interface HookEvent {
|
|
15
|
+
source_app: string;
|
|
16
|
+
session_id: string;
|
|
17
|
+
hook_event_type: string;
|
|
18
|
+
payload: Record<string, any>;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
timestamp_local: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Get local timestamp using system timezone
|
|
24
|
+
function getLocalTimestamp(): string {
|
|
25
|
+
const date = new Date();
|
|
26
|
+
const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
27
|
+
const localDate = new Date(date.toLocaleString('en-US', { timeZone: tz }));
|
|
28
|
+
|
|
29
|
+
const year = localDate.getFullYear();
|
|
30
|
+
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
|
31
|
+
const day = String(localDate.getDate()).padStart(2, '0');
|
|
32
|
+
const hours = String(localDate.getHours()).padStart(2, '0');
|
|
33
|
+
const minutes = String(localDate.getMinutes()).padStart(2, '0');
|
|
34
|
+
const seconds = String(localDate.getSeconds()).padStart(2, '0');
|
|
35
|
+
|
|
36
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${tz}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get current events file path
|
|
40
|
+
function getEventsFilePath(): string {
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
43
|
+
const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));
|
|
44
|
+
const year = localDate.getFullYear();
|
|
45
|
+
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
|
46
|
+
const day = String(localDate.getDate()).padStart(2, '0');
|
|
47
|
+
|
|
48
|
+
const monthDir = join(HISTORY_DIR, 'raw-outputs', `${year}-${month}`);
|
|
49
|
+
|
|
50
|
+
// Ensure directory exists
|
|
51
|
+
if (!existsSync(monthDir)) {
|
|
52
|
+
mkdirSync(monthDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return join(monthDir, `${year}-${month}-${day}_all-events.jsonl`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Session-to-agent mapping functions
|
|
59
|
+
function getSessionMappingFile(): string {
|
|
60
|
+
return join(PAI_DIR, 'agent-sessions.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getAgentForSession(sessionId: string): string {
|
|
64
|
+
try {
|
|
65
|
+
const mappingFile = getSessionMappingFile();
|
|
66
|
+
if (existsSync(mappingFile)) {
|
|
67
|
+
const mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
|
|
68
|
+
return mappings[sessionId] || 'pai';
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// Ignore errors, default to pai
|
|
72
|
+
}
|
|
73
|
+
return 'pai';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setAgentForSession(sessionId: string, agentName: string): void {
|
|
77
|
+
try {
|
|
78
|
+
const mappingFile = getSessionMappingFile();
|
|
79
|
+
let mappings: Record<string, string> = {};
|
|
80
|
+
|
|
81
|
+
if (existsSync(mappingFile)) {
|
|
82
|
+
mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mappings[sessionId] = agentName;
|
|
86
|
+
writeFileSync(mappingFile, JSON.stringify(mappings, null, 2), 'utf-8');
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// Silently fail - don't block
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
try {
|
|
94
|
+
// Get event type from command line args
|
|
95
|
+
const args = process.argv.slice(2);
|
|
96
|
+
const eventTypeIndex = args.indexOf('--event-type');
|
|
97
|
+
|
|
98
|
+
if (eventTypeIndex === -1) {
|
|
99
|
+
console.error('Missing --event-type argument');
|
|
100
|
+
process.exit(0); // Don't block Claude Code
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const eventType = args[eventTypeIndex + 1];
|
|
104
|
+
|
|
105
|
+
// Read hook data from stdin
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
for await (const chunk of process.stdin) {
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
}
|
|
110
|
+
const stdinData = Buffer.concat(chunks).toString('utf-8');
|
|
111
|
+
const hookData = JSON.parse(stdinData);
|
|
112
|
+
|
|
113
|
+
// Detect agent type from session mapping or payload
|
|
114
|
+
const sessionId = hookData.session_id || 'main';
|
|
115
|
+
let agentName = getAgentForSession(sessionId);
|
|
116
|
+
|
|
117
|
+
// If this is a Task tool launching a subagent, update the session mapping
|
|
118
|
+
if (hookData.tool_name === 'Task' && hookData.tool_input?.subagent_type) {
|
|
119
|
+
agentName = hookData.tool_input.subagent_type;
|
|
120
|
+
setAgentForSession(sessionId, agentName);
|
|
121
|
+
}
|
|
122
|
+
// If this is a SubagentStop or Stop event, reset to pai
|
|
123
|
+
else if (eventType === 'SubagentStop' || eventType === 'Stop') {
|
|
124
|
+
agentName = 'pai';
|
|
125
|
+
setAgentForSession(sessionId, 'pai');
|
|
126
|
+
}
|
|
127
|
+
// Check if CLAUDE_CODE_AGENT env variable is set (for subagents)
|
|
128
|
+
else if (process.env.CLAUDE_CODE_AGENT) {
|
|
129
|
+
agentName = process.env.CLAUDE_CODE_AGENT;
|
|
130
|
+
setAgentForSession(sessionId, agentName);
|
|
131
|
+
}
|
|
132
|
+
// Check if agent type is in the payload (alternative detection method)
|
|
133
|
+
else if (hookData.agent_type) {
|
|
134
|
+
agentName = hookData.agent_type;
|
|
135
|
+
setAgentForSession(sessionId, agentName);
|
|
136
|
+
}
|
|
137
|
+
// Check if this is from a subagent based on cwd containing 'agent'
|
|
138
|
+
else if (hookData.cwd && hookData.cwd.includes('/agents/')) {
|
|
139
|
+
// Extract agent name from path like "/agents/designer/"
|
|
140
|
+
const agentMatch = hookData.cwd.match(/\/agents\/([^\/]+)/);
|
|
141
|
+
if (agentMatch) {
|
|
142
|
+
agentName = agentMatch[1];
|
|
143
|
+
setAgentForSession(sessionId, agentName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Create base event object
|
|
148
|
+
let event: HookEvent = {
|
|
149
|
+
source_app: agentName,
|
|
150
|
+
session_id: hookData.session_id || 'main',
|
|
151
|
+
hook_event_type: eventType,
|
|
152
|
+
payload: hookData,
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
timestamp_local: getLocalTimestamp()
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Enrich with agent instance metadata if this is a Task tool call
|
|
158
|
+
if (isAgentSpawningCall(hookData.tool_name, hookData.tool_input)) {
|
|
159
|
+
event = enrichEventWithAgentMetadata(
|
|
160
|
+
event,
|
|
161
|
+
hookData.tool_input,
|
|
162
|
+
hookData.description
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Append to events file
|
|
167
|
+
const eventsFile = getEventsFilePath();
|
|
168
|
+
const jsonLine = JSON.stringify(event) + '\n';
|
|
169
|
+
appendFileSync(eventsFile, jsonLine, 'utf-8');
|
|
170
|
+
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// Silently fail - don't block Claude Code
|
|
173
|
+
console.error('Event capture error:', error);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
main();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect-environment.ts
|
|
3
|
+
*
|
|
4
|
+
* Environment detection utilities for PAI hooks.
|
|
5
|
+
*
|
|
6
|
+
* Determines if the system is running in a remote environment (SSH, cloud, etc.)
|
|
7
|
+
* to conditionally adjust hook behaviour.
|
|
8
|
+
*
|
|
9
|
+
* Detection Logic (in priority order):
|
|
10
|
+
* 1. Check PAI_ENVIRONMENT env var (explicit override)
|
|
11
|
+
* 2. Check for SSH indicators (SSH_CLIENT, SSH_TTY)
|
|
12
|
+
* 3. Default to local environment
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if we're running in an SSH session
|
|
17
|
+
* Looks for SSH_CLIENT or SSH_TTY environment variables
|
|
18
|
+
*/
|
|
19
|
+
function isSSHSession(): boolean {
|
|
20
|
+
return !!(process.env.SSH_CLIENT || process.env.SSH_TTY);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Main detection function - determines if we're in a remote environment
|
|
25
|
+
*
|
|
26
|
+
* Returns:
|
|
27
|
+
* - true: Remote environment
|
|
28
|
+
* - false: Local environment
|
|
29
|
+
*
|
|
30
|
+
* Detection priority:
|
|
31
|
+
* 1. PAI_ENVIRONMENT === 'remote' → true
|
|
32
|
+
* 2. PAI_ENVIRONMENT === 'local' → false
|
|
33
|
+
* 3. SSH_CLIENT or SSH_TTY set → true
|
|
34
|
+
* 4. Otherwise → false (local)
|
|
35
|
+
*/
|
|
36
|
+
export function isRemoteEnvironment(): boolean {
|
|
37
|
+
// 1. Check explicit environment override
|
|
38
|
+
const paiEnv = process.env.PAI_ENVIRONMENT?.toLowerCase();
|
|
39
|
+
if (paiEnv === 'remote') {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (paiEnv === 'local') {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. Check for SSH session
|
|
47
|
+
if (isSSHSession()) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Default to local environment
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
export interface AgentInstanceMetadata {
|
|
14
|
+
agent_instance_id?: string; // "perplexity-researcher-1" (full ID)
|
|
15
|
+
agent_type?: string; // "perplexity-researcher" (base type)
|
|
16
|
+
instance_number?: number; // 1 (sequence number)
|
|
17
|
+
parent_session_id?: string; // Session that spawned this agent
|
|
18
|
+
parent_task_id?: string; // Task ID that spawned this agent
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract agent instance ID from Task tool input
|
|
23
|
+
*
|
|
24
|
+
* Looks for patterns in priority order:
|
|
25
|
+
* 1. [agent-type-N] in description (e.g., "Research topic [perplexity-researcher-1]")
|
|
26
|
+
* 2. [AGENT_INSTANCE: agent-type-N] in prompt
|
|
27
|
+
* 3. subagent_type field (fallback to just type, no instance number)
|
|
28
|
+
*
|
|
29
|
+
* @param toolInput The tool input object from PreToolUse/PostToolUse hooks
|
|
30
|
+
* @param description Optional description field from tool input
|
|
31
|
+
* @returns Metadata object with extracted information
|
|
32
|
+
*/
|
|
33
|
+
export function extractAgentInstanceId(
|
|
34
|
+
toolInput: any,
|
|
35
|
+
description?: string
|
|
36
|
+
): AgentInstanceMetadata {
|
|
37
|
+
const result: AgentInstanceMetadata = {};
|
|
38
|
+
|
|
39
|
+
// Strategy 1: Extract from description [agent-type-N]
|
|
40
|
+
// Example: "Research consumer complaints [perplexity-researcher-1]"
|
|
41
|
+
if (description) {
|
|
42
|
+
const descMatch = description.match(/\[([a-z-]+-researcher)-(\d+)\]/);
|
|
43
|
+
if (descMatch) {
|
|
44
|
+
result.agent_type = descMatch[1];
|
|
45
|
+
result.instance_number = parseInt(descMatch[2], 10);
|
|
46
|
+
result.agent_instance_id = `${result.agent_type}-${result.instance_number}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Strategy 2: Extract from prompt [AGENT_INSTANCE: ...]
|
|
51
|
+
// Example: "[AGENT_INSTANCE: perplexity-researcher-1]"
|
|
52
|
+
if (!result.agent_instance_id && toolInput?.prompt && typeof toolInput.prompt === 'string') {
|
|
53
|
+
const promptMatch = toolInput.prompt.match(/\[AGENT_INSTANCE:\s*([^\]]+)\]/);
|
|
54
|
+
if (promptMatch) {
|
|
55
|
+
result.agent_instance_id = promptMatch[1].trim();
|
|
56
|
+
|
|
57
|
+
// Parse agent type and instance number from ID
|
|
58
|
+
const parts = result.agent_instance_id.match(/^([a-z-]+)-(\d+)$/);
|
|
59
|
+
if (parts) {
|
|
60
|
+
result.agent_type = parts[1];
|
|
61
|
+
result.instance_number = parseInt(parts[2], 10);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Strategy 3: Extract parent session from prompt
|
|
67
|
+
// Example: "[PARENT_SESSION: b7062b5a-03d3-4168-9555-a748e0b2efa3]"
|
|
68
|
+
if (toolInput?.prompt && typeof toolInput.prompt === 'string') {
|
|
69
|
+
const parentSessionMatch = toolInput.prompt.match(/\[PARENT_SESSION:\s*([^\]]+)\]/);
|
|
70
|
+
if (parentSessionMatch) {
|
|
71
|
+
result.parent_session_id = parentSessionMatch[1].trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extract parent task from prompt
|
|
75
|
+
// Example: "[PARENT_TASK: research_1731445892345]"
|
|
76
|
+
const parentTaskMatch = toolInput.prompt.match(/\[PARENT_TASK:\s*([^\]]+)\]/);
|
|
77
|
+
if (parentTaskMatch) {
|
|
78
|
+
result.parent_task_id = parentTaskMatch[1].trim();
|
|
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
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Enrich event with agent metadata
|
|
93
|
+
*
|
|
94
|
+
* Takes a base event object and adds agent instance metadata to it.
|
|
95
|
+
* Returns a new object with merged metadata.
|
|
96
|
+
*
|
|
97
|
+
* @param event Base event object (from PreToolUse/PostToolUse)
|
|
98
|
+
* @param toolInput Tool input object
|
|
99
|
+
* @param description Optional description field
|
|
100
|
+
* @returns Enriched event with agent metadata
|
|
101
|
+
*/
|
|
102
|
+
export function enrichEventWithAgentMetadata(
|
|
103
|
+
event: any,
|
|
104
|
+
toolInput: any,
|
|
105
|
+
description?: string
|
|
106
|
+
): any {
|
|
107
|
+
const metadata = extractAgentInstanceId(toolInput, description);
|
|
108
|
+
|
|
109
|
+
// Only add fields that have values (keep events clean)
|
|
110
|
+
const enrichedEvent = { ...event };
|
|
111
|
+
|
|
112
|
+
if (metadata.agent_instance_id) {
|
|
113
|
+
enrichedEvent.agent_instance_id = metadata.agent_instance_id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (metadata.agent_type) {
|
|
117
|
+
enrichedEvent.agent_type = metadata.agent_type;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (metadata.instance_number !== undefined) {
|
|
121
|
+
enrichedEvent.instance_number = metadata.instance_number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (metadata.parent_session_id) {
|
|
125
|
+
enrichedEvent.parent_session_id = metadata.parent_session_id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (metadata.parent_task_id) {
|
|
129
|
+
enrichedEvent.parent_task_id = metadata.parent_task_id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return enrichedEvent;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if a tool call is spawning a subagent
|
|
137
|
+
*
|
|
138
|
+
* @param toolName Name of the tool being called
|
|
139
|
+
* @param toolInput Tool input object
|
|
140
|
+
* @returns true if this is a Task tool call spawning an agent
|
|
141
|
+
*/
|
|
142
|
+
export function isAgentSpawningCall(toolName: string, toolInput: any): boolean {
|
|
143
|
+
return toolName === 'Task' && toolInput?.subagent_type !== undefined;
|
|
144
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
* ALSO loads .env file from PAI_DIR so all hooks get environment variables
|
|
8
|
+
* without relying on Claude Code's settings.json injection.
|
|
9
|
+
*
|
|
10
|
+
* Usage in hooks:
|
|
11
|
+
* import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { resolve, join } from 'path';
|
|
16
|
+
import { existsSync, readFileSync } from 'fs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load .env file and inject into process.env
|
|
20
|
+
* Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed
|
|
21
|
+
*/
|
|
22
|
+
function loadEnvFile(): void {
|
|
23
|
+
// Check common locations for .env
|
|
24
|
+
const possiblePaths = [
|
|
25
|
+
resolve(process.env.PAI_DIR || '', '.env'),
|
|
26
|
+
resolve(homedir(), '.claude', '.env'),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const envPath of possiblePaths) {
|
|
30
|
+
if (existsSync(envPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
33
|
+
for (const line of content.split('\n')) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
// Skip comments and empty lines
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
37
|
+
|
|
38
|
+
const eqIndex = trimmed.indexOf('=');
|
|
39
|
+
if (eqIndex > 0) {
|
|
40
|
+
const key = trimmed.substring(0, eqIndex).trim();
|
|
41
|
+
let value = trimmed.substring(eqIndex + 1).trim();
|
|
42
|
+
|
|
43
|
+
// Remove surrounding quotes if present
|
|
44
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
45
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
46
|
+
value = value.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Expand $HOME and ~ in values
|
|
50
|
+
value = value.replace(/\$HOME/g, homedir());
|
|
51
|
+
value = value.replace(/^~(?=\/|$)/, homedir());
|
|
52
|
+
|
|
53
|
+
// Only set if not already defined (env vars take precedence)
|
|
54
|
+
if (process.env[key] === undefined) {
|
|
55
|
+
process.env[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Found and loaded, don't check other paths
|
|
60
|
+
break;
|
|
61
|
+
} catch {
|
|
62
|
+
// Silently continue if .env can't be read
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load .env FIRST, before any other initialization
|
|
69
|
+
loadEnvFile();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Smart PAI_DIR detection with fallback
|
|
73
|
+
* Priority:
|
|
74
|
+
* 1. PAI_DIR environment variable (if set)
|
|
75
|
+
* 2. ~/.claude (standard location)
|
|
76
|
+
*/
|
|
77
|
+
export const PAI_DIR = process.env.PAI_DIR
|
|
78
|
+
? resolve(process.env.PAI_DIR)
|
|
79
|
+
: resolve(homedir(), '.claude');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Common PAI directories
|
|
83
|
+
*/
|
|
84
|
+
export const HOOKS_DIR = join(PAI_DIR, 'Hooks');
|
|
85
|
+
export const SKILLS_DIR = join(PAI_DIR, 'Skills');
|
|
86
|
+
export const AGENTS_DIR = join(PAI_DIR, 'Agents');
|
|
87
|
+
export const HISTORY_DIR = join(PAI_DIR, 'History');
|
|
88
|
+
export const COMMANDS_DIR = join(PAI_DIR, 'Commands');
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate PAI directory structure on first import
|
|
92
|
+
* This fails fast with a clear error if PAI is misconfigured
|
|
93
|
+
*/
|
|
94
|
+
function validatePAIStructure(): void {
|
|
95
|
+
if (!existsSync(PAI_DIR)) {
|
|
96
|
+
console.error(`PAI_DIR does not exist: ${PAI_DIR}`);
|
|
97
|
+
console.error(` Expected ~/.claude or set PAI_DIR environment variable`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!existsSync(HOOKS_DIR)) {
|
|
102
|
+
console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);
|
|
103
|
+
console.error(` Your PAI_DIR may be misconfigured`);
|
|
104
|
+
console.error(` Current PAI_DIR: ${PAI_DIR}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run validation on module import
|
|
110
|
+
// This ensures any hook that imports this module will fail fast if paths are wrong
|
|
111
|
+
validatePAIStructure();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Helper to get history file path with date-based organization
|
|
115
|
+
*/
|
|
116
|
+
export function getHistoryFilePath(subdir: string, filename: string): string {
|
|
117
|
+
const now = new Date();
|
|
118
|
+
const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
119
|
+
const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));
|
|
120
|
+
const year = localDate.getFullYear();
|
|
121
|
+
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
|
122
|
+
|
|
123
|
+
return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);
|
|
124
|
+
}
|